Using Ramda with React and Redux: A Functional Deep Dive - Part 1
March 01, 2019
Functional programming is all the rage amongst javascript developers these days. With the recent addition of React hooks, it’s now possible to have a frontend codebase without using a single class. In addition, Redux and React already encourage a functional style of programming with things like stateless components, transactional state, and middleware for isolation of side-effects. This leads to an interesting prospect: what if we go all-in on functional programming for our frontend project?
In this post, we’ll go through some benefits of this approach, enumerating useful patterns, coding conventions, and effects on maintainability that using strict declarative and functional JS gets you, as well as calling out some of the concessions you’ll be making compared to using a more procedural or object-oriented style.
The Status Quo
In my experience, the average frontend React and Redux codebase suffers from a handful of common issues:
Components have too much logic embedded in them
View logic is usually tangled up with rerender, data processing, or fetching logic. This makes
components hard to reason about, refactor, and maintain. Because these are usually class components,
it also leaves room open for more junior or full-stack engineers without a strong grasp of frontend
best practices to start storing application state in state
or, worse, this
.
Reducers are hard to reason about
Large reducers tend to use the canonical switch
statement along with some heavy procedural data
manipulation, usually with loops and local mutation. This leads to bugs when things accidentally get
mutated and thus relevant components fail to rerender.
Underutilizing selectors
Too many components and connected containers have to dig into and process various aspects of state to get to the data they want, thus forcing them to embed knowledge of the current structure of your state object. This makes reducers doubly hard to refactor since these two things get tangled together.
As the codebase scales, many of these negative aspects tend to become more exacerbated and developer productivity starts to slow down. After seeing this a few times, you start to ask yourself - can we do better?
Going All-In
I’m not going to try to convince you of the benefits of functional programming; if you want to be convinced, you can check out:
- Why functional programming matters
- Why curry helps
- An introduction to functional programming in Javascript
I claim that heavily applying the following functional paradigms to our frontend application helps us mitigate, and in some cases solve, all of the aforementioned problems:
- Functional composition
- Data immutability
- Function purity and segregation of side-effects
Functional composition
Functional composition encourages the creation of small, general purpose functions that are composed together to create larger and more complex functions. For example:
const sliceString = (string, start, end) => string.slice(start, end);
const stringHead = string => sliceString(string, 0, 1);
const isA = character => ['a', 'A'].includes(character);
const isHeadA = string => isA(stringHead(string));
isHeadA("ant") // true
// to do this pattern more generally, we can use compose
const isHeadA = compose(
isA,
stringHead,
);
// or in reverse order, pipe, which lets us
// pipe our input through functions from top to bottom
const isHeadA = pipe(
stringHead,
isA,
);
This technique can be used in both component containers and selectors. It can both share common logic between and segregate application logic from your containers and components.
Function purity and segregation of side-effects
All functions in our application are pure, with the exception of middleware for things like network requests or I/O. In this case, we maintain a strict convention of segregating our side-effectful calls from the pure computation that influences their results. Our functional core does all the heavy lifting and logic, and the result is passed to a dumb API that produces a side-effect with the result given to it by the functional core (i.e. our reducers).
In other words, data transformation only happens in pure functions, never in a a side-effectful one. Gary Bernhardt has a great talk on this that I highly encourage you to check out.
Data immutability
To change a piece of data, you must always return a new instance of that data type. Methods like
.push
on arrays and setting properties on existing objects are not allowed:
const array = [1];
const object = {};
// bad
array.push(2);
object.prop = 2;
// good
const newArray = array.concat(2) /* or */ [...array, 2];
const newObject = { ...object, prop: 2 };
Data immutability will prevent us from ever running into rerendering bugs and generally most errors caused by implicit data mutation. If you’ve ever had an async callback mess with an object you were working on in the middle of a function, you’ll know what I’m talking about.
The Tools
Here is where Ramda comes in. Ramda is a javascript utility library for functional style programming, focusing on creating data pipelines.
Each function is curried, never mutates its input, and accepts the data to be operated on as its last parameter. This gives us immutability, easy function composition, and helps to segregate side effects for us right out of the box. You can read more on the philosophy of the library here.
As we will see, these patterns end up having an exceptional synergy with many of the patterns in React and Redux. We’ll be making use of Ramda extensively in our examples, and, as a result, will be writing many of our functions as pipelines in a point-free style.
Keep in mind, we don’t necessarily need Ramda. The code in the rest of this article can be written with any functional library, or even with standard JS functions and a little bit of diligence. This is about applying general functional programming paradigms, not a tutorial on the library itself. Personally, I find Ramda to be the best bang for your buck when it comes to executing functional programming correctly in Javascript.
Code
Now that we’ve established our philosophy, let’s see how it plays out.
Selectors
The majority of our selectors are simple pipelines within a createSelector
if we’re using
reselect:
/**
* Lets say our state looks something like
* {
* homePrices: [
* { isForSale: true, price: 1000 },
* { isForSale: false, price: 2000 },
* ...,
* ],
* analytics: {
* pages: {
* landing: {
* hasBeenViewed: true,
* hasSignedUp: false,
* },
* },
* },
* }
*
*/
const getHomePriceSelector = ({ homePrices }) => homePrices;
const getAnalyticsSelector = ({ analytics }) => analytics;
// Simple paths selectors
const getHasUserSignedUp = createSelector(
getAnalyticsSelector,
R.path(['pages', 'landing', 'hasSignedUp']),
);
const getHasUserViewedLanding = createSelector(
getAnalyticsSelector,
R.path(['pages', 'landing', 'hasBeenViewed']),
);
// Point-free composition of selectors
const getUserHasViewedAndSignedUp = createSelector(
getHasUserSignedUp,
getHasUserViewedLanding,
R.and,
);
// More complicated filter / reduce pipeline
const getTotalValueOfHomesForSale = createSelector(
getHomePriceSelector,
R.pipe(
R.filter(R.prop('isForSale')),
R.pluck('price'),
R.reduce(R.add, 0),
),
);
We can use Ramda’s object utilities to dig into nested state for simple selectors, compose selectors cleanly, and create data pipelines that scale to an arbitrary number of steps.
Since selectors are unary functions of state -> someData
, they fit extremely well within our
pipeline model. In addition, since Ramda never mutates data, we can have faith that memoization will
always work as intended and won’t cause unnecessary rerenders in our receiving components.
Because all of our functions are pure, factoring out common logic between selectors is also a breeze:
// Factor our some logic
const getHomesForSale = createSelector(
getHomePriceSelector,
R.filter(R.prop('isForSale')),
);
const getAreHomesForSale = createSelector(
getHomesForSale,
R.complement(R.isEmpty),
);
// More complicated filter / reduce pipeline
const getTotalValueOfHomesForSale = createSelector(
getHomeForSale,
R.pipe(
R.pluck('price'),
R.reduce(R.add, 0),
),
);
This makes selectors easy to maintain, test, and reason about.
In general, the point-free notation, while sometimes hard to reason about (having
x => console.log(x) || x
is a great debugging tool to stick in pipelines), makes it harder for
other developers to come in and introduce mutation and potential bugs into your pipeline without a
large rewrite and significant understanding of FP practices.
It makes it easy to add additional steps in your pipeline, and otherwise creates a slight barrier to entry that protects your code from being muddled back into an imperative style. If you are working in a section of a larger codebase with looser standards, this is a very good defense to have.
Reducers
Reducers are where some really strong Redux and Ramda synergies start to arise. Let’s start by
taking the createReducer
tool that is popularly used to reduceswitch
statement boilerplate:
const createReducer = (initialState, handlers) => (
state = initialState,
action,
) =>
handlers.hasOwnProperty(action.type)
? handlers[action.type](state, action)
: state;
This lets us write our reducers in a more declarative style:
const myReducer = createReducer(INITIAL_STATE, {
[MY_ACTION]: (state, action) => ...reducer logic
});
This also fits well with Ramda, since most reducer work is just setting some object value or doing data processing:
const taxFormReducer = createReducer(INITIAL_STATE, {
// Setting a field
[SET_MARRIAGE_STATUS]: (state, action) =>
R.assocPath(['form1090', 'marriageStatus'], action.status, state),
// Append to a list
[ADD_EXEMPTION]: (state, action) =>
R.evolve(
{
exemptions: R.append(action.exemption),
},
state,
),
// Deep merging
[IMPORT_W2]: (state, action) => R.mergeDeepLeft(action.w2JSON, state),
});
After doing this a few times, we start to see a pattern emerge. Since Ramda has a data-last philosophy, all of our reducer functions take state as a final parameter. What if we could embrace this pattern and pass state to these handlers implicitly?
Since Ramda functions are all curried, omitting state
as a final parameter in our handlers would
turn our handlers from a function (state, action) -> state
to (state, action) -> state -> state
.
We can then swap the argument order and curry our handler itself to get a function
action -> state -> state
, letting us write handlers like:
const taxFormReducer = createReducer(INITIAL_STATE, {
// Setting a field
[SET_MARRIAGE_STATUS]: action => R.assocPath(
['form1090', 'marriageStatus'],
action.status,
),
// Append to a list
[ADD_EXEMPTION]: action =>
R.evolve({
exemptions: R.append(action.exemption),
}),
// Deep merging
[IMPORT_W2]: action => R.mergeDeepLeft(action.w2JSON),
});
We can easily write a modified utility, createCurriedReducer
to let us write reducers like this:
const createCurriedReducer = (initialState, handlers) => (
state = initialState,
action,
) =>
handlers.hasOwnProperty(action.type)
// swap the argument order and expect a curried handler
? handlers[action.type](action)(state) : state;
This lets us write reducers by simply declaring the data transformation we want to apply to our state, and the utility takes care of applying it for us.
No more worrying about accidental data mutation!
As usual, there is also an escape hatch for when you need to access some data from the previous state to inform your transformation:
const taxFormReducer = createReducer(INITIAL_STATE, {
// Gives you access to state in your reducer again
[ADD_EXEMPTION]: action => state => R.evolve({
exemptions: state.exemptions < MAX_EXEMPTIONS
? R.append(action.exemption)
: R.identity
}, state),
});
Containers and Components
Stateless functional components, by definition, already lend themselves to a functional style of programming:
// Simply a unary function props -> jsx
const myComponent = ({ props }) => <div>Hello {props.username}</div>;
Where functional paradigms add a lot of utility is in containers and higher order components. Let’s take a basic connected container component:
import VideoPlaybackComponent from 'components/VideoPlayback.react';
const mapStateToProps = state => ({
username: getUsername(state),
currentVideoUrl: getCurrentVideoUrl(state),
videoRating: getVideoRating(state),
});
// using the shorthand syntax
const mapDispatchToProps = {
playVideo: actions.play,
};
export default connect(
mapStateToProps,
mapDispatchToProps,
)(VideoPlaybackComponent);
The first thing to notice here is that the component and the container live in separate files. This
helps to enforce a boundary between your view code (the component) and your controller (the
container). Even though connect
is a function, it is essentially creating a higher order component
when called with mapStateToProps
and mapDispatchToProps
, thus we put it in a different file.
One thing to notice here is that connect
is curried - calling it with those two arguments returns
another function that then expects a React component to wrap. We can take advantage of this when we
want to add additional functionality to our component beyond connect
ing it to state and dispatch.
We’ll use recompose in our examples, but everything here can
also be done with hooks.
Let’s say we want to add some state handlers, a substitution for using setState
within a class
component. Since containers are also unary functions, component -> component
, we can once again
use a pipeline. Let’s say we want to make it a pure component as well:
import VideoPlaybackComponent from 'components/VideoPlayback.react';
import { pure, withStateHandlers } from 'recompose';
const mapStateToProps = state => ({
username: getUsername(state),
currentVideoUrl: getCurrentVideoUrl(state),
videoRating: getVideoRating(state),
});
// using the shorthand syntax
const mapDispatchToProps = { playVideo: actions.play };
export default R.pipe( pure, withStateHandlers(...), connect( mapStateToProps, mapDispatchToProps, ),)(VideoPlaybackComponent);
Now, our component is pure, wrapped in some state handlers, and connected to our Redux store.
Additional HOCs can be added by just adding another line to our pipe
. This leads to more modular
and composable HOCs that fit within the context of a pipe. You could easily imagine the same pattern
extending to data fetching, perhaps with GraphQL, or with some manual DOM handlers, if really
needed, to keep pure React code separate from escape hatch imperative DOM manipulation via refs
.
We can also notice another pattern in the typical mapStateToProps
- it’s just an object spec that
applies various functions to the same input argument. Ramda has a utility for that, applySpec
:
const mapStateToProps = state => ({
username: getUsername(state),
currentVideoUrl: getCurrentVideoUrl(state),
videoRating: getVideoRating(state),
});
// becomes...
const mapStateToProps = R.applySpec({
username: getUsername,
currentVideoUrl: getCurrentVideoUrl,
videoRating: getVideoRating,
});
Once again this lets us write our containers in a laconic point-free style.
Testing
One of the biggest benefits of a functional style is testability. In order to test a pure function, all you have to do is give it an input and check it’s output. Since all of our functions doing computation of interest are pure, testing becomes a breeze. Let’s look at testing reducers as you would normally do it:
describe('my test', () => {
it('passes', () => {
const initialState = getInitialState();
const state = reduce(initialState, myAction);
expect(selector(state)).toBe(whatever);
});
});
As you can see, in order to test a reducer, all you have to do is reduce
some initial state and an
action and check some value of your state.
What happens when you want to apply multiple actions in one test?
describe('my test', () => {
it('passes', () => {
const initialState = getInitialState();
let state = reduce(initialState, myAction);
state = reduce(state, mySecondAction);
state = reduce(state, myThirdAction);
expect(selector(state)).toBe(whatever);
});
});
This starts to become rather repetitive. Let’s create a function, applyActions
, that lets us pass
in an array of actions to be applied in order:
describe('my test', () => {
it('passes', () => {
const state = applyActions(getInitialState(), [
myAction,
mySecondAction,
myThirdAction,
]);
expect(selector(state)).toBe(whatever);
});
});
How do we do it? Currying of course!
const applyActions = R.reduce(reduce);
That’s it. Calling R.reduce
with a single reducer argument returns a two-argument function that
takes in the start state and a list of values to reduce. In our case, that’s just initialState
and
our actions.
In general, as long as we listen to Gary Bernhardt’s advice and maintain a boundary between our pure, functional core and our side-effectful interface to the user, testing remains trivial and efficient.
The Tradeoffs
Now that we’ve seen some of the benefits of this approach, we will discuss the tradeoffs we’ll have to make to get these benefits in Part Two.