React Hooks or Redux – choosing the right state management strategy
Christian Miles
Posted on August 16, 2021
In my day job, at Cambridge Intelligence, I work with a lot of React developers.
When I ask them about their preferred state management strategy, I get a mixed response. Some rely on Redux, the popular state container for JavaScript applications, while others prefer React Hooks.
In this article, I’ll explore both and introduce a third, hybrid approach. Throughout, I’ll make useful recommendations based on experience and discussions I’ve had with developers building production-grade data visualization tools with our React SDKs.
Application state fundamentals
When you’re building a React web app, all of the information is held in what we call state. So to update the app, we just need to update that state. Simple, right?
Not exactly. State management is a notoriously difficult problem.
To manage state is to control the data passed between the different components of your application. It’s important to consider the best strategy for sharing this data – how can we make it easier for developers to understand and control how data flows between components?
Using a well-understood framework like React means most core application lifecycle events are handled for you. But there are lots of options for implementation and state management. These options can be overwhelming as preferences change and best practices shift.
React Hooks as a replacement for Redux?
Over the last few years the React state management landscape has shifted dramatically. The influential Redux library with its emphasis on immutability has inspired core changes to React – most notably Hooks added in version 16.8.
See Harnessing Hooks in your ReGraph code for a bit more detail on Hooks.
Many other fresh approaches to state management have surfaced, and there are countless JavaScript libraries to consider. As we’re in the data visualization business, I’ll focus on recommendations for building graph analytics applications.
State management strategy planning
Let’s consider two pieces of the state management puzzle: what state do I need to store and why?
Not all state in your data visualization application is the same. You’ll have different types of data to pass around. Here’s a simplified but representative component architecture of a graph analytics project:
<App>
<VisualizationContainer>
<Chart/>
<Timeline/>
</VisualizationContainer>
<Sidebar/>
</App>
Our ReGraph Chart component is paired with a KronoGraph Timeline in a VisualizationContainer.
We want to show nodes and links (items) in the Chart to see the connections and share that data with the Timeline component so we can dig into the timestamps in our dataset. Our Sidebar includes UI elements to run searches and update our Chart and Timeline. We’re aiming for a graph and timeline visualization that looks like this:
When you plan your state management strategy it’s worth plotting your state on an axis to understand what you’re dealing with:
These are the guiding principles I’ve followed:
Item types: unless you’re building a general-purpose application, the node types in your chart and timeline (person, place, vehicle) are likely to be static. I can define them ahead of time as they don’t need to be in state, so they’re in a configuration file in our repository.
Item styles: it’s logical to include the core style of each node and link type alongside definitions of which nodes and links to expect.
Theme selection: giving users the option to toggle between dark and light mode, results in a relatively volatile state item to track the user’s preference.
UI state: other parts of the UI state are both static and temporary. There’s no need to store all form interactions in our state though (a common mistake that can result in unresponsive applications).
-
Item position & timeline range: your node positions (and the network for which the nodes are found) are very volatile:
- in their ReGraph charts, users can run a layout and manually position nodes however they like.
- in their KronoGraph timeline, users can zoom into a time range of interest.
- it’s a common requirement to persist these positions across different sessions so users can continue where they left off.
Undo/redo stack: this is a popular request to allow users to reverse their actions. In advanced applications, you may need to persist this undo/redo data across multiple sessions, but it’s a common design decision to scope these for the current session only.
Data from API: it’s likely that you’ll need to receive data from an external location or API. This data is dynamic and temporary. A strong application caches results from an endpoint and persists the relevant bits in our application state.
React Hooks vs Redux - is there another way?
Now that we've characterized our state, we can consider the hierarchy of data in our application. There are two main methods of state management to choose from:
Handle state in our components and pass between them as necessary using Hooks. This approach, often referred to as “prop drilling” or “bringing state up”, is recommended for basic applications.
Use some sort of global store that all components can access. Libraries like Redux provide capabilities for this.
But there is a third, even better method: a hybrid approach that pairs Hooks with a careful consideration of what state is important.
Let’s use our data visualization application to explore these methods further, starting with Redux.
Redux state management
Since its release in 2015, Redux has become a key part of the React ecosystem.
Redux uses immutability to simplify application development and logic. By forcing immutability on all items in state we can trace changes to our data and avoid accidental data mutations that could lead to bugs.
Over time Redux has become a little bloated, but it’s still an excellent choice for large applications with complex state. To help cut down on the complexity of the library, the Redux Toolkit was introduced in 2019. It’s now the recommended way to use Redux.
Consistent state updates
A core concept in Redux is that of a reducer. Familiar to those with functional programming experience, this is a function that takes multiple inputs and “reduces” it down to a single output. In state management this is extended to the idea that you can take one or many state update directives and result in a consistent state update for your chart.
Let’s consider a standard graph visualization use case: adding and removing nodes from a chart. I want this to be in my global store, so I create a “slice” of state in my store. Here’s my store creation code in store.js:
import { configureStore } from '@reduxjs/toolkit';
import itemsReducer from '../features/chart/itemsSlice';
export const store = configureStore({
reducer: {
items: itemsReducer
}
});
To let other components in my application access the store, I wrap the app as follows:
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import { store } from './app/store';
import { Provider } from 'react-redux';
import * as serviceWorker from './serviceWorker';
ReactDOM.render(
<React.StrictMode>
<Provider store={store}>
<App></App>
</Provider>
</React.StrictMode>,
document.getElementById('root')
);
The Provider piece means that anything downstream can access that store. Over in itemsSlice.js I define my slice of state for these items:
import { createSlice, createEntityAdapter } from '@reduxjs/toolkit';
export const itemsAdapter = createEntityAdapter();
const initialState = itemsAdapter.getInitialState();
export const itemsSlice = createSlice({
name: 'items',
initialState,
reducers: {
addItems: itemsAdapter.addMany,
addItem: itemsAdapter.addOne,
removeItems: itemsAdapter.removeMany,
removeItem: itemsAdapter.removeOne,
},
});
export const { addItems, addItem, removeItems, removeItem } = itemsSlice.actions;
export const { select, selectAll, selectTotal } = itemsAdapter.getSelectors((state) => state.items);
export default itemsSlice.reducer;
There’s lots going on here:
Our ReGraph items prop is an object of nodes and links, indexed by ID. The core data structure is very common, and Redux Toolkit has some helper functions to work with data in this format. Here I’m using createEntityAdapter to take advantage of the addMany, addOne, removeMany, removeOne functions provided by the adapter.
In Redux, a Selector allows us to get a piece of state out of the store. I’m taking advantage of getSelectors on the adapter to avoid writing the state querying code myself. Slick!
Finally, I export everything so I can use it elsewhere in my application
Over in my application code, I can take advantage of the store, reducer and selectors:
import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { Chart } from 'regraph';
import { addItems, addItem, removeItems, removeItem, selectAll, selectTotal } from './itemsSlice';
import mapValues from 'lodash/mapValues';
import styles from './NetworkChart.module.css';
const colors = ['#173753', '#6daedb', '#2892d7', '#1b4353', '#1d70a2'];
const defaultNodeStyle = (label) => ({
label: {
text: `User ${label}`,
backgroundColor: 'transparent',
color: 'white',
},
border: { width: 2, color: 'white' },
color: colors[(label - 1) % colors.length],
});
const styleItems = (items, theme) => {
return mapValues(items, (item) => {
if (item.id1) {
return { ...defaultLinkStyle(item.id), ...theme[item.type] };
} else {
return { ...defaultNodeStyle(item.id), ...theme[item.type] };
}
});
};
export function NetworkChart() {
const dispatch = useDispatch();
const items = useSelector(selectAll);
const itemCount = useSelector(selectTotal);
const theme = { user: {} };
const styledItems = styleItems(items, theme);
return (
<div className={styles.container}>
<Chart
items={styledItems}
animation={{ animate: false }}
options={{ backgroundColor: 'rgba(0,0,0,0)', navigation: false, overview: false }}
>
import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { Chart } from 'regraph';
import { addItems, addItem, removeItems, removeItem, selectAll, selectTotal } from './itemsSlice';
import mapValues from 'lodash/mapValues';
import styles from './NetworkChart.module.css';
const colors = ['#173753', '#6daedb', '#2892d7', '#1b4353', '#1d70a2'];
const defaultNodeStyle = (label) => ({
label: {
text: `User ${label}`,
backgroundColor: 'transparent',
color: 'white',
},
border: { width: 2, color: 'white' },
color: colors[(label - 1) % colors.length],
});
const styleItems = (items, theme) => {
return mapValues(items, (item) => {
if (item.id1) {
return { ...defaultLinkStyle(item.id), ...theme[item.type] };
} else {
return { ...defaultNodeStyle(item.id), ...theme[item.type] };
}
});
};
export function NetworkChart() {
const dispatch = useDispatch();
const items = useSelector(selectAll);
const itemCount = useSelector(selectTotal);
const theme = { user: {} };
const styledItems = styleItems(items, theme);
return (
<div className={styles.container}>
<Chart
items={styledItems}
animation={{ animate: false }}
options={{ backgroundColor: 'rgba(0,0,0,0)', navigation: false, overview: false }}
/>
<div className={styles.row}>
<button
className={styles.button}
aria-label="Add items"
onClick={() => dispatch(addItem({ id: itemCount + 1, type: 'user' }))}
>
Add User
</button>
<button
className={styles.button}
aria-label="Remove Items"
onClick={() => dispatch(removeItem(itemCount))}
>
Remove User
</button>
</div>
</div>
);
}
</Chart>
<div className={styles.row}>
<button
className={styles.button}
aria-label="Add items"
onClick={() => dispatch(addItem({ id: itemCount + 1, type: 'user' }))}
>
Add User
</button>
<button
className={styles.button}
aria-label="Remove Items"
onClick={() => dispatch(removeItem(itemCount))}
>
Remove User
</button>
</div>
</div>
);
}
Using Redux Hooks useSelector, I can easily take advantage of the selectors provided by my slice code. Meanwhile, useDispatch allows us to “dispatch” an action against our state – another helpful bit of Redux that allows us to make state changes.
Redux Toolkit uses the popular immutability library, Immer, for clean updates to state without the need to write complex cloning and updating logic. This is abstracted away further by my itemsAdapter.
Here, I’ve styled my chart items directly in my component. A smart option would be to follow this Styled Components tutorial for graph visualization.
When you’re fetching data from an external source, the lines between application state and database storage are a little blurred. RTK Query (from the creators of Redux Toolkit) and other popular libraries like react-query work well with Redux to avoid writing functionality like caches from scratch. We’ll cover the use of RTK Query in a future blog post.
If I was relying solely on Redux, I’d put my entire application state in the global store and access that from each of my components. In reality only some of your visualization component state needs to be in the store – a hybrid approach of Hooks and Redux delivers the best of both worlds.
Let’s turn our attention to Hooks.
Modern React as a replacement for Redux?
You may be reluctant to introduce yet another dependency to your application. When Hooks were added to React in 2019, it went a long way towards replicating the deep functionality of Redux.
Let’s see how we can harness Hooks in our application, together with the Context API and prop drilling.
Prop drilling
In this fantastic article from Kent C. Dodds, he makes this important point:
Keep state as close to where it's needed as possible.
For our example, this means that if I wish to share data between the Chart and Timeline components (and I know it won’t be needed anywhere else) I can keep things as simple as possible through prop drilling.
When used sparingly, this is an effective, clean way to share state across components. If I bring my state up to the VisualizationContainer in my application, I can pass the data into each component as a prop.
Sure, if I need to pass this up and down a complex hierarchy, I may as well reach for Redux or similar. But for our basic application, it makes sense to keep things simple.
ReGraph does a great job of controlling its internal state, thanks to its strong API and handful of well-designed props. There’s no need for a lot of these props to bleed outside of the component that holds our Chart.
React Hooks
For our Chart component, I want to use simple useState and useRef Hooks to handle basic configuration in state. ReGraph will handle multiple updates to the state gracefully so it’s reasonable ergonomics to use separate useState calls, unless you’re confident that you’ll be often updating groups of props together.
const [layout, setLayout] = useState(defaults.layout);
setLayout({name: 'sequential'})
The useReducer hook is delightfully familiar to those who’ve used Redux.
import React, { useState, useReducer, useCallback } from 'react';
const [combine, combineDispatch] = useReducer(combineReducer, defaults.combine)
const combineItems = useCallback(property => combineDispatch({ type: 'COMBINE', property }), [])
const uncombineItems = useCallback(property => combineDispatch({ type: 'UNCOMBINE', property }), [])
function combineReducer(combine, action) {
const newCombine = { ...combine };
if (action.type === 'COMBINE') {
newCombine.properties.push(action.property);
newCombine.level = combine.level + 1;
}
else if (action.type === 'UNCOMBINE') {
newCombine.properties.pop();
newCombine.level = combine.level - 1;
} else {
throw new Error(`No action ${action.type} found`);
}
return newCombine;
}
Notice in this example I’m writing my reducer by hand. Without Redux Toolkit’s help, I need to mutate my combine objects. This means writing more code but again, for small applications and clean APIs like ReGraph, this is reasonable.
There’s a conceptual difference between React’s useReducer vs. reducers in Redux. In React we write as many reducers as we like: they’re just Hooks to make it easier to update state. In Redux these act against the central store using slices as conceptual separation.
We could write a custom hook for ReGraph to encapsulate all the props we need to take advantage of. Here’s how that could look:
import React, { useState, useReducer, useCallback } from 'react';
import { has, merge, mapValues, isEmpty } from 'lodash';
import { chart as defaults } from 'defaults';
const linkColor = '#fff9c4';
const nodeColor = '#FF6D66';
function isNode(item) {
return item.id1 == null && item.id2 == null;
}
function transformItems(items, itemFn) {
return mapValues(items, (item, id) => {
const newItem = itemFn(item, id);
return newItem ? merge({}, item, newItem) : item
});
};
function styleItems(items) {
return transformItems(items, item => {
return defaults.styles[isNode(item) ? 'node' : 'link'];
});
}
function itemsReducer(items, action) {
const newItems = { ...items };
if (action.type === 'SET') {
return { ...newItems, ...styleItems(action.newItems) }
}
else if (action.type === 'REMOVE') {
Object.keys(action.removeItems).forEach(removeId => { delete newItems[removeId]; })
return newItems;
} else {
throw new Error(`No action ${action.type} found`);
}
}
function combineReducer(combine, action) {
const newCombine = { ...combine };
if (action.type === 'COMBINE') {
newCombine.properties.push(action.property);
newCombine.level = combine.level + 1;
}
else if (action.type === 'UNCOMBINE') {
newCombine.properties.pop();
newCombine.level = combine.level - 1;
} else {
throw new Error(`No action ${action.type} found`);
}
return newCombine;
}
function useChart({ initialItems = {} }) {
const styledItems = styleItems(initialItems)
const [items, dispatch] = useReducer(itemsReducer, styledItems)
const addItems = useCallback(newItems => dispatch({ type: 'SET', newItems }), [])
const removeItems = useCallback(removeItems => dispatch({ type: 'REMOVE', removeItems }), [])
const [combine, combineDispatch] = useReducer(combineReducer, defaults.combine)
const combineItems = useCallback(property => combineDispatch({ type: 'COMBINE', property }), [])
const uncombineItems = useCallback(property => combineDispatch({ type: 'UNCOMBINE', property }), [])
const [animation, setAnimation] = useState(defaults.animation);
const [view, setView] = useState(defaults.view);
const [layout, setLayout] = useState(defaults.layout);
const [positions, setPositions] = useState(defaults.positions);
const [selection, setSelection] = useState(defaults.selection);
const [map, setMap] = useState(defaults.map);
const [options, setOptions] = useState(defaults.options);
const chartState = { items, options, layout, positions, selection, map, animation, combine }
return [chartState, { addItems, removeItems, setPositions, setSelection, combineItems, uncombineItems }]
}
export { useChart, isNode }
Notice that there are a number of useState calls for each individual prop in use by ReGraph. I could put these into a simple object and handle updates with a single function but I like splitting them out – it’s a personal preference.
For a simple implementation, I’m using lodash merge to merge my item updates. In production, I’d reach for Immer or similar to improve performance.
Context API
My custom useChart hook is nice if I only need to Control the chart from one component. But what if I want to drive it using my SideBar?
This is the problem that Redux solved in a global fashion. Is there anything we can do without Redux?
Context has been a part of the React API for a number of years. We can use it to make data accessible across a user-defined scope, so it can help us to achieve something approaching the global store we created in Redux.
What’s the modern way to take advantage of Context? There’s a hook for that!
There’s some debate as to whether Context and useContext are viable and reasonable replacements for Redux. One thing’s for sure: it’s a clean API to consistently share context across components.
Taking inspiration from another blog post from Kent C. Dodds I can take this hook and “contextify” it into its own thing:
import React, { useState, useReducer, useCallback } from 'react';
import merge from 'lodash/merge';
import mapValues from 'lodash/mapValues';
import { chart as defaults } from 'defaults';
const ChartContext = React.createContext();
function isNode(item) {
return item.id1 == null && item.id2 == null;
}
function transformItems(items, itemFn) {
return mapValues(items, (item, id) => {
const newItem = itemFn(item, id);
return newItem ? merge({}, item, newItem) : item;
});
}
function styleItems(items) {
return transformItems(items, (item) => {
return defaults.styles[isNode(item) ? 'node' : 'link'];
});
}
function itemsReducer(items, action) {
const newItems = { ...items };
if (action.type === 'SET') {
return { ...newItems, ...styleItems(action.newItems) };
} else if (action.type === 'REMOVE') {
Object.keys(action.removeItems).forEach((removeId) => {
delete newItems[removeId];
});
return newItems;
} else {
throw new Error(`No action ${action.type} found`);
}
}
function combineReducer(combine, action) {
const newCombine = { ...combine };
if (action.type === 'COMBINE') {
newCombine.properties.push(action.property);
newCombine.level = combine.level + 1;
} else if (action.type === 'UNCOMBINE') {
newCombine.properties.pop();
newCombine.level = combine.level - 1;
} else {
throw new Error(`No action ${action.type} found`);
}
return newCombine;
}
function ChartProvider({ children }) {
const [items, dispatch] = useReducer(itemsReducer, {});
const addItems = useCallback((newItems) => dispatch({ type: 'SET', newItems }), []);
const removeItems = useCallback((removeItems) => dispatch({ type: 'REMOVE', removeItems }), []);
const [combine, combineDispatch] = useReducer(combineReducer, defaults.combine);
const combineItems = useCallback((property) => combineDispatch({ type: 'COMBINE', property }),[]);
const uncombineItems = useCallback((property) => combineDispatch({ type: 'UNCOMBINE', property }),[]);
const [animation, setAnimation] = useState(defaults.animation);
const [view, setView] = useState(defaults.view);
const [layout, setLayout] = useState(defaults.layout);
const [positions, setPositions] = useState(defaults.positions);
const [selection, setSelection] = useState(defaults.selection);
const [map, setMap] = useState(defaults.map);
const [options, setOptions] = useState(defaults.options);
const value = [
{ view, items, options, layout, positions, selection, map, animation, combine },
{ addItems, removeItems, setOptions, setMap, setView, setLayout, setAnimation, setPositions, setSelection, combineItems, uncombineItems },
];
return <ChartContext.Provider value={value}>{children}</ChartContext.Provider>;
}
function useChart() {
const context = React.useContext(ChartContext);
if (context === undefined) {
throw new Error('useChart must be used within a ChartProvider');
}
return context;
}
export { ChartProvider, useChart };
Now I wrap any component that needs access to the Chart details and setters with my custom ChartProvider context:
<App>
<ChartProvider>
<VisualizationContainer>
<Chart/>
<Timeline/>
</VisualizationContainer>
<Sidebar/>
</ChartProvider>
</App>
Then I import useChart and get both the current chart state AND some dispatch functions anywhere in my application hierarchy. All with a simple call to useChart:
const [state, { setLayout }] = useChart();
Context vs Redux?
The critical difference between the use of Context and Redux store is that a Context isn’t automatically available to the rest of your application: it’s up to you to define the scope.
This is a feature, not a bug.
It makes us more intentional with logic, but it’s a clear reason why context isn’t a drop-in replacement for Redux. Just like with useReducer, it’s common practice to create many different contexts for use across your application.
What works for you?
We’ve covered a lot in this article! We started with a comprehensive state management strategy using the Redux Toolkit to take advantage of a global store. Then we explored how a simple application could use core React Hooks to get the same benefits.
How do you solve your state management conundrums? Where do you stand on the React Hooks vs Redux debate?
Posted on August 16, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.