Bulletproof React: Understanding The Functional Reactive Approach
Hadrian Hughes
Posted on April 21, 2020
The principles of functional programming are becoming more in fashion every day. More and more traditionally imperative languages are implementing lambda functions, immutability and lazy evaluation. It's exciting to see, and even more encouraging to see that React is at the forefront of these changes.
React has always encouraged functional principles in some capacity; Redux has long been the most popular approach to building large scale apps. However, the advent of React hooks has made it clear that this preference for functional over imperative is very intentional, and it's here to stay. With all that said, I still hear the complaint that Redux is confusing, or seems "magical". There are also plenty of developers who think Redux is made obsolete by React's Context API, and while there's some truth to this, there are still some huge benefits to be gained by using the Redux approach, so I'd like to dedicate a post to demystifying just how it works and to outlining those benefits.
The most obvious benefit of using Redux would be that it moves all of your app state to a single source of truth, making it much easier to ensure that components stay in sync with one another. But there's more. Let's start by laying out all the key components of the Redux architecture.
Notice there is no 'store' entity in the diagram because the store is a transient value passed to the view from the reducer.
The Store
At the core of everything in a Redux app is the store. It's easy to think of the store as a container for all of your state which you can update, but the store is in fact immutable. It's a value passed through your app just like arguments to a function, and the only way to "change" the value is to call the function again with different arguments.
To better visualise this, let's create a very simple functional reactive app in JavaScript.
// <button id="myButton"></button> defined in HTML
function myApp(state) {
function dispatch() {
myApp(state + 1);
}
const btn = document.getElementById('myButton');
btn.innerHTML = state;
btn.onclick = dispatch;
}
myApp(0);
We define our app as a function myApp
which accepts our state as its argument. Within the context of myApp
we define a closure called dispatch
, which simply calls myApp
again with updated state (the previous state + 1). We then use our state as the button's text label, and bind dispatch
to the button's onclick
listener. Finally, we bootstrap the app with a starting state value of 0. Now every time we click the button, its value will increase by 1 as myApp
reruns with the updated state.
Simple, right? There's no magic here - this is functional reactive programming in its most basic form.
To bring it back to Redux, the state
argument in our example would be the store in Redux. It's immutable - or more to the point, mutating it would have no effect because the app has already consumed it and finished running - and we have to use a dispatcher function to make changes to it. Redux also exposes a dispatch
function which we either pass down to components via props, or we use the react-redux higher-order component connect
to avoid props drilling. However, Redux's dispatcher function doesn't directly rerun the app, but the extra step is part of what makes it so powerful.
Actions And The Reducer
When the dispatch
function is called following a user interaction, it is passed an action. An action consists of a type and a payload. This action is then passed through a reducer function. This is where the magic happens. The following is a simple example of a reducer function:
const initialState = 0;
function reducer(state = initialState, action) {
switch (action.type) {
case 'ADD':
return state + action.payload;
case 'SUBTRACT':
return state - action.payload;
default:
return state;
}
}
Our reducer function accepts two arguments: the current state and the action passed to the dispatcher function. We check the action type and apply a transformation based on it. If the type is ADD
, we return the current state plus the action payload; if the type is SUBTRACT
, we return the current state minus the action payload. This returned value will become the app's new state.
const myAddAction = {
type: 'ADD',
payload: 3
};
reducer(5, myAddAction); // This would perform 5 + 3 to return 8
Transforming our state using a reducer function means the state can only be transformed in a finite number of ways, which are all immediately visible when you view this function. No matter what we do, we can't multiply or divide the state without adding a new case to the reducer's switch statement. This is very powerful: no more tracking down where a logic error is coming from. If a state update happens it must be happening in the reducer function; the only question is where the dispatcher function was called from, which is easy to track down using a stack trace.
Side Effects
It's time to go a little deeper into functional terminology (but only a little). Our app is now more deterministic thanks to all state updates being centralised in one function. However, how will our app communicate with the outside world?
In functional programming, any computation which doesn't consist of a function returning an expression based solely on its arguments is called a side effect. An app without side effects is useless; at the very least we need a way for our app to receive input and give output, and since both of these things rely on conditions being met in the outside world (e.g. the code being run in a browser with a DOM API for us to interact with) they would be considered side effects. However, just because our apps rely on side effects doesn't mean we should pretend they don't exist. Thinking proactively about where the side effects in your app are allows you to reduce the number of them you create, and to manage them safely.
Thankfully, React deals with IO for us and allows us to write pure computations safely behind the abstraction of the virtual DOM, but what if we want to get some data from a remote API over HTTP? Typically we'd just place this in a useEffect
hook in one of our components, but this is less than ideal. For example, what if we have two of the same component on one page, and both of the instances perform the HTTP request? One of them would be completely redundant. We can program around this using finicky conditionals, but who wants that? Wouldn't it be the icing on the cake to not have to go through the ordeal?
We can solve this by using a Redux middleware. A middleware sits between the dispatcher function and the reducer function. An interaction causes dispatch
to be called with an action; the action is then passed through any middlewares we set up, before finally reaching the reducer.
Let's say we're building an app which includes a list of users. On the initial page load we might dispatch an action to fetch the list of users from an API:
{ type: 'FETCH_USERS' }
This isn't an action type which is recognised by the reducer, so it won't trigger a state update. Instead, we tell a middleware to wait for any actions with a type of FETCH_USERS
and then perform a get request to the remote API. When a response comes back, the middleware calls the dispatcher function again with a new action:
{
type: 'SET_USERS',
payload: users // 'users' is the response body
}
This subsequent SET_USERS
action is picked up by the reducer and the app reruns with the new state which includes the fetched list of users. No searching for the component responsible for fetching a piece of data, we know it always happens in a middleware.
The most popular Redux middleware libraries are redux-saga and redux-thunk. They use very different approaches but both have their pros and cons.
In Summary
So what have we gained? In short, transparency and determinism. Each aspect of our app is now clearly defined and has a dedicated place. The view is handled by React, but we can now be sure that it is composed of only pure functions which receive their props and return markup. All state transformations are triggered by actions and performed by the reducer function. All side effects (besides IO which is handled by React) are isolated within middlewares where nothing else depends on their success.
Using this approach, our apps can scale indefinitely with minimal runtime errors and without logic errors becoming impossible to track down and manage.
Posted on April 21, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.