Written by Glenn Stovall✏️
Redux is a prime example of a software library that trades one problem for another.
While redux enables you to manage application state globally using the flux pattern, it also leads to filling your application with tedious, boilerplate code.
Even the most straightforward changes require declaring types, actions, and adding another case statement to an already colossal switch statement.
As state and changes continue to increase in complexity, your reducers become more complicated and convoluted.
What if you could remove most of that boilerplate?
Enter: Redux-Leaves
Redux-Leaves is a JavaScript library that provides a new framework for how you handle state changes in your redux application. In a standard redux setup, you have one or maybe a few controllers managing different parts of the application.
Instead, Redux-Leaves treats each node of data, or “leaf” in their nomenclature, as a first-class citizen. Each leaf comes with built-in reducers, so you don’t have to write them.
This enables you to remove a lot of boilerplate from your application.
Let’s compare the two approaches and then look at how to tackle moving from a traditional redux setup to one using Redux-Leaves.
How to get started with Redux-Leaves
Let’s begin by building a simple greenfield application that uses only redux and Redux-Leaves. This way, you can try out the tool before trying to add it to an existing project.
Then, we’ll look at how you could approach added Redux-Leaves to an existing project. We’ll use create-react-app
to set up an environment with a build chain and other tooling quickly.
Starting your project
npx create-react-app my-redux-leaves-demo && cd my-redux-leaves-demo
yarn init
yarn add redux redux-leaves
For this example, we’ll use Twitter as our model. We’ll store a list of tweets and add to it.
Within a store.js
file, let’s take a look at at a redux case and compare that to how Redux-Leaves works.
Adding a record: Redux version
Typically, whenever you need to add a new mutation to state, you create:
- A type constant
- An action creator function
- A case in the reducer’s switch statement.
Here’s our redux example that adds a tweet:
Adding a record: Redux-Leaves version
import { createStore } from 'redux'
const initialState = {
tweets: [],
}
const types = {
ADD_TWEET: 'ADD_TWEET',
}
const actions = {
pushTweet: (tweet) => ({
type: types.ADD_TWEET,
payload: tweet,
})
}
const reducer = (state = initialState, action) => {
switch (action.type) {
case 'ADD_TWEET':
return {
...state,
tweets: [
...state.tweets,
action.payload,
]
}
default:
return state
}
}
const store = createStore(reducer)
store.dispatch(actions.pushTweet({ text: 'hello', likes: 0 }))
With Redux-Leaves, there is no need to define a reducer function. The Redux-Leaves initialization function provides a reducer we can pass to createStore
.
Also, it provides an actions object that provides action creator functions, so we don’t have to worry about coding those from scratch either.
With all of that taken care of, there is no need to declare type constants. Bye-bye, boilerplate!
Here’s a piece of functionally equivalent code to the above, written with Redux-Leaves:
import { createStore } from 'redux'
import { reduxLeaves } from 'redux-leaves’
const initialState = {
tweets: [],
}
const [reducer, actions] = reduxLeaves(initialState)
const store = createStore(reducer)
store.dispatch(actions.tweets.create.push({ text: 'hello', likes: 0 }))
It’s much more concise than the previous example. As your requirements grow, the results are more drastic.
In a standard redux application, you have to write new types and expand your reducer for every mutation.
Redux-Leaves handles many cases out-of-the-box, so that isn’t the case.
How do you dispatch those mutations?
With Redux-Leaves built-in action creators. Each piece of data in the state is a leaf. In our example, the tweets array is a leaf.
With objects, leaves can be nested. The tweet itself is considered a leaf, and each subfield of it is also a leaf, and so on. Each has action creators of their own.
An overview of action creators for various data types
Redux-Leaves provides three actions creators for every type of leaf, regardless of type:
- Update: set the value of a leaf to anything you want
- Reset: set the value of a leaf back to whatever it was in the initial state
-
Clear: depends on the data type. Numbers become 0. Booleans become false. Strings, arrays, and objects become empty(
''
,[]
, and{}
respectively)
In addition to these, Redux-Leaves provides some additional creators that are type-specific. For example, leaves of the boolean type have on, off, and toggle action creators.
For a complete list, refer to the Redux-Leaves documentation.
Two ways to create actions
You can use the create function directly and dispatch actions that way, or you can declare actions that you can call elsewhere.
The second way maps more closely to how redux currently operates, but also for that reason creates more boilerplate.
I’ll leave it up to you to decide which approach works best for your needs.
// method #1
store.dispatch(actions.tweets.create.push({ text: 'hello', likes: 0 }))
// method #2
const addTweet = actions.tweets.create.push
store.dispatch(addTweet({ text: 'hello', likes: 0 }))
Creating complex actions with bundle
Boilerplate code saves time, but it isn’t able to handle every real-world use case. What if you want to update more than one leaf at a time?
Redux-Leaves provides a bundle function that combines many actions into one.
If you wanted to keep track of the most recent timestamp when you add a tweet, it would look like this:
const updateTweet = (tweet) => bundle([
actions.most_recent.create.update(Date.now()),
actions.tweets.create.push(tweet),
], 'UPDATE_WITH_RECENCY_UPDATE')
store.dispatch(updateTweet({ text: 'hello', likes: 0 }))
The first argument is an array of actions to dispatch, and the second is an optional custom type.
But even then, there are probably some cases that this won’t handle either. What if you need more logic in your reducer?
What if you need to reference one part of the state while updating another? For these cases, it’s also possible to code custom leaf reducers.
This extensibility is what makes Redux-Leaves shine: It provides enough built-in functionality to handle simple use cases, and the ability to expand on that functionality when needed.
Creating custom reducer actions with leaf reducers
When tweeting, all a user has to do is type into a text box and hit submit.
They aren’t responsible for providing all of the metadata that goes with it. A better API would be one that only requires a string to create a tweet, and abstract away the actual structure.
This situation is a good use case for a custom leaf reducer.
The core shape of a leaf reducer is the same as with other reducers: it takes in a state and action and returns an updated version of the state.
Where they differ, though, is that a leaf reducer does not relate directly to a single piece of data. Leaf reducers are callable on any leaf in your application.
That’s yet another way Redux-Leaves helps you avoid repetition.
Also note that the state
in leaf reducer is not referencing the entire global state — only the leaf it was called on.
In our example, leafState
is the tweets array.
If you need to reference the global state, you can pass it in as an optional 3rd argument.
const pushTweet = (leafState, action) => [
...leafState,
{
text: action.payload,
likes: 0,
last_liked: null,
pinned: false,
}
]
Add custom leaf reducers to the reduxLeaves
function. The key in the object becomes its function signature in the application.
const customReducers = {
pushTweet: pushTweet,
}
const [reducer, actions] = reduxLeaves(initialState, customReducers)
const store = createStore(reducer)
Then, dispatching actions for custom reducers looks just like the built-in ones:
store.dispatch(actions.tweets.create.pushTweet('Hello, world!'))
console.log('leaves version', store.getState())
Outputs the following:
{
tweets: [
{
text: “Hello, World!”,
likes: 0,
last_liked: null,
pinned: false,
}
]
}
Migrating to Redux-Leaves
If you are working on an existing project and considering moving Redux-Leaves, you probably don’t want to take the whole thing out at once.
A much safer strategy would be to replace existing redux code one action at a time.
If you have tests in place for your application — which you should before attempting to refactor to a library like this — then this process should be a smooth and easy one.
Replace one action and run the tests. When they pass, repeat.
To do this, I recommend using the reduce-reducers Redux utility. Reduce-reducers enables the combining of existing reducers with new ones.
yarn add reduce-reducers
With this tool, it is possible to add Redux-Leaves to your application, without rewriting any code (yet).
import { createStore } from 'redux'
import { reduxLeaves } from 'redux-leaves'
import reduceReducers from 'reduce-reducers’
Const initialState = {
// initial state
}
const myOldReducer = (state = initialState, action) => {
// big case statement goes here
}
const leafReducers = {} // we’ll put custom reducers here if/when we need them
const [reducer, actions] = reduxLeaves(initialState, leafReducers)
const comboReducer = reduceReducers(myOldReducer, reducer)
const store = createStore(comboReducer)
This update should not change the behavior of your application. The store is updatable by both the old reducers and the new one.
Therefore, you can remove and replace actions one-by-one instead of rewriting everything at once.
Eventually, you’ll be able to make one of those tasty pull requests that make your codebase a few thousand lines shorter without changing functionality.
If you like, this change enables using Redux-Leaves for new code without modifying existing cases.
Conclusion
Removing the complexity of one library by adding another library is a counterintuitive proposition in my book.
On the one hand, you can leverage Redux-Leaves to reduce boilerplate code and increase the speed with which developers can add functionality.
However, adding another library means there is another API developers on the team need to be familiar with.
If you are working alone or on a small team, then the learning curve may not be an issue. Only you and your team can know if redux is the right decision for your project.
Is the reduced codebase and the faster pace of development worth the added dependency and learning required? That’s up to you.
Plug: LogRocket, a DVR for web apps
LogRocket is a frontend logging tool that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.
In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page apps.
Try it for free.
The post Reducing Redux boilerplate with Redux-Leaves appeared first on LogRocket Blog.
Top comments (0)