Read the original article in CodeThat.today
React Hooks have been around in a while so I've taken the opportunity to explore how we can handle state management with them. My aim is to understand what works and what doesn't when we plan to use them in practice and at scale.
I've reviewed some options and techniques that are out the wild together with some explanations and criticisms. You can find some of those examples in this GitHub Repo.
Base Example
Before we start let's describe the initial components that we are going to use in the subsequent sections.
Let's say we have a Todo application. We have the following simplified structure of containers and components:
First is the Header
that contains the input form for the TodoList.
type HeaderProps = {
addTodo?: (text: string) => void;
}
const Header = ({ addTodo }: HeaderProps ) => {
const onSave = (text: string) => {
if (text.length !== 0) {
addTodo && addTodo(text);
}
};
return (
<header className="header">
<h1>todos</h1>
<TodoTextInput
newTodo={true}
onSave={onSave}
placeholder="Tell me what you want to do!"
/>
</header>
)
}
Where:
type TodoTextInputProps = {
text?: string
editing?: boolean;
placeholder?: string;
onSave: (text: string) => void;
newTodo: boolean;
}
type TodoTextInputState = {
text: string;
}
export class TodoTextInput extends React.Component<TodoTextInputProps, TodoTextInputState> {
state = {
text: this.props.text || ''
};
handleSubmit = (e: React.KeyboardEvent<any>) => {
const text = e.currentTarget.value.trim();
if (e.which === 13) { // Enter Key
this.props.onSave(text);
if (this.props.newTodo) {
this.setState({ text: '' });
}
}
};
handleChange = (e: React.FormEvent<HTMLInputElement>) => {
this.setState({ text: e.currentTarget.value });
};
handleBlur = (e: React.FormEvent<HTMLInputElement>) => {
if (!this.props.newTodo) {
this.props.onSave(e.currentTarget.value);
}
};
render() {
return (
<input
className={classnames({
edit: this.props.editing,
"new-todo": this.props.newTodo
})}
type="text"
placeholder={this.props.placeholder}
autoFocus={true}
value={this.state.text}
onBlur={this.handleBlur}
onChange={this.handleChange}
onKeyDown={this.handleSubmit}
/>
);
}
}
Then we have the MainSection
where we display the Todos:
type MainSectionProps = {
todos: Todo[];
deleteTodo: (id: number) => void;
editTodo: (id: number, text: string) => void;
toggleTodo: (id: number) => void;
}
const MainSection = ({
todos,
deleteTodo,
editTodo,
toggleTodo,
}: MainSectionProps) => {
return (
<section className="main">
<TodoList
todos={todos}
deleteTodo={deleteTodo}
editTodo={editTodo}
toggleTodo={toggleTodo}
/>
</section>
);
};
type TodoListProps = MainSectionProps
const TodoList = ({ todos, editTodo, deleteTodo, toggleTodo }: TodoListProps) => (
<ul className="todo-list">
{todos.map((todo: Todo) => (
<TodoItem
key={todo.id}
todo={todo}
editTodo={editTodo}
toggleTodo={toggleTodo}
deleteTodo={deleteTodo}
/>
))}
</ul>
);
Where:
type TodoItemProps = Pick<MainSectionProps, 'toggleTodo' | 'deleteTodo' | 'editTodo'> & {
todo: Todo;
}
type TodoItemPropsState = {
editing: boolean;
}
export class TodoItem extends React.Component<TodoItemProps, TodoItemPropsState> {
state = {
editing: false
};
handleDoubleClick = () => {
this.setState({ editing: true });
};
handleSave = (id: number, text: string) => {
if (text.length === 0) {
this.props.deleteTodo(id);
} else {
this.props.editTodo(id, text);
}
this.setState({ editing: false });
};
render() {
const { todo, toggleTodo, deleteTodo } = this.props;
let element;
if (this.state.editing) {
element = (
<TodoTextInput
text={todo.text}
editing={this.state.editing}
onSave={text => this.handleSave(todo.id, text)}
newTodo={false}
/>
);
} else {
element = (
<div className="view">
<input
className="toggle"
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo.id)}
/>
<label onDoubleClick={this.handleDoubleClick}>{todo.text}</label>
<button className="destroy" onClick={() => deleteTodo(todo.id)} />
</div>
);
}
return (
<li
className={classnames({
completed: todo.completed,
editing: this.state.editing
})}
>
{element}
</li>
);
}
}
This code is very typical that you can find on any TodoMVC example online. Notice that we defer any logic to higher components using callbacks.
Let's see now the most popular ways that you can use React hooks for state management.
Custom Hook State
This is the most straightforward way. We provide a Custom Hook that will offer all the necessary business logic that the container needs, for example:
type Todo = {
id: number;
completed: boolean;
text: string;
}
const useTodos = () => {
const [todos, setTodos] = useState<Todo[]>([]);
const addTodo = (text: string) => {
setTodos([
...todos,
{
id: todos.reduce((maxId, todo) => Math.max(todo.id, maxId), -1) + 1,
completed: false,
text
}
]);
};
const deleteTodo = (id: number) => {
setTodos(todos.filter(todo => todo.id !== id));
};
const editTodo = (id: number, text: string) => {
setTodos(todos.map(todo => (todo.id === id ? { ...todo, text } : todo)));
};
const toggleTodo = (id: number) => {
setTodos(
todos.map(
todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
);
};
return [
todos,
{
addTodo,
deleteTodo,
editTodo,
toggleTodo,
}
];
};
const App = () => {
const [
todos,
{ addTodo, deleteTodo, editTodo, toggleTodo }
]: any = useTodos();
return (
<div>
<Header addTodo={addTodo} />
<MainSection
todos={todos}
deleteTodo={deleteTodo}
editTodo={editTodo}
toggleTodo={toggleTodo}
/>
</div>
);
};
Here all the state management logic is encapsulated in a reusable useTodos
hook. We return the list of todos and the list of operations that comes with them. When we call one of the modifying functions the list of todos is updated and the whole list gets re-rendered as shown in the example below:
Things I like: The simplicity. There is no central storage or complex provisioning. We just take it and use it.
Things I don't like: The simplicity. It may not scale well in large applications or when we have a complex dataset. For small programs that deal with a small scoped domain it's perfect.
Custom Hooks + React Context
This builds on top of the ideas explained in this article. We use a combination of a custom hook to manage state:
import React from "react";
import { useState, useMemo, useContext } from "react";
import { Todo } from "../Example5";
const AppContext = React.createContext({});
/**
* Our custom React hook to manage state
*/
type AppState = {
todos: Todo[];
};
const useAppState = () => {
const initialState: AppState = { todos: [] };
// Manage the state using React.useState()
const [state, setState] = useState<AppState>(initialState);
// Build our actions. We'll use useMemo() as an optimization,
// so this will only ever be called once.
const actions = useMemo(() => getActions(setState), [setState]);
return { state, actions };
};
// Define your actions as functions that call setState().
// It's a bit like Redux's dispatch(), but as individual
// functions.
const getActions = (
setState: React.Dispatch<React.SetStateAction<AppState>>
) => ({
deleteTodo: (id: number) => {
setState((prevState: AppState) => ({
...prevState,
todos: prevState.todos.filter((todo: Todo) => todo.id !== id)
}));
},
editTodo: (id: number, text: string) => {
setState((prevState: AppState) => ({
...prevState,
todos: prevState.todos.map((todo: Todo) =>
todo.id === id ? { ...todo, text } : todo
)
}));
},
toggleTodo: (id: number) => {
setState((prevState: AppState) => ({
...prevState,
todos: prevState.todos.map((todo: Todo) =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
}));
},
addTodo: (text: string) => {
setState((prevState: AppState) => ({
...prevState,
todos: [
...prevState.todos,
{
id:
prevState.todos.reduce(
(maxId, todo) => Math.max(todo.id, maxId),
-1
) + 1,
completed: false,
text
}
]
}));
}
});
// Sub-components can use this function. It will pick up the
// `state` and `actions` given by useAppState() higher in the
// component tree.
const useAppContext = (): any => {
return useContext(AppContext);
};
export { AppContext, useAppState, useAppContext };
Then we can use it like:
const TodoList: React.FC = () => {
const { state, actions } = useAppContext();
return (
<div>
<Header addTodo={actions.addTodo} />
<MainSection
todos={state.todos}
deleteTodo={actions.deleteTodo}
editTodo={actions.editTodo}
toggleTodo={actions.toggleTodo}
/>
</div>
);
};
const App: React.FC = () => {
const { state, actions } = useAppState();
return (
<AppContext.Provider value={{ state, actions }}>
<div>
<TodoList />
</div>
</AppContext.Provider>
);
};
export default App;
In the above example we separate the actions from the state and we use a global AppContext
as a Provider for those values. Then any component can invoke the useAppContext
to retrieve that context for use.
Things I like: Separating actions from state. Using React.Context
API as an improvement on the previous example.
Things I don't like: We may need further customisation. For example we need to logically namespace the actions or the state. Overall its a good solution.
Redux + Hooks + Proxies
The last example is based on top of the ideas explained in this article. Here we keep our good old Redux store with all our reducers, initial state etc:
import { createStore } from 'redux';
import { Todo } from './models';
export type AppState = {
todos: Todo[];
};
const reducer = (state = AppState, action: any) => {
switch (action.type) {
case 'ADD_TODO':
return { ...state, todos: [
...state.todos,
{
id: state.todos.reduce((maxId: number, todo: Todo) => Math.max(todo.id, maxId), -1) + 1,
completed: false,
text: action.text
}
] };
case 'DELETE_TODO':
return { ...state, todos: state.todos.filter((todo: Todo) => todo.id !== action.id) };
case 'TOGGLE_TODO':
return { ...state, todos: state.todos.map((todo: Todo) =>
todo.id === action.id ? { ...todo, completed: !todo.completed } : todo
) };
case 'EDIT_TODO':
return { ...state, todos: state.todos.map((todo: Todo) =>
todo.id === action.id ? { ...todo, text: action.text } : todo
) };
default:
return state;
}
};
const store = createStore(reducer);
export default store;
Then we need to create the following mumbo jumbo that performs all the logic:
import React, { useContext, useEffect, useReducer, useRef, useMemo } from 'react';
const ReduxStoreContext = React.createContext({});
export const ReduxProvider = ({ store, children }: any) => (
<ReduxStoreContext.Provider value={store}>
{children}
</ReduxStoreContext.Provider>
);
export const useReduxDispatch = () => {
const store: any = useContext(ReduxStoreContext);
return store.dispatch;
};
const forcedReducer = (state: any) => !state;
const useForceUpdate = () => useReducer(forcedReducer, false)[1];
export const useReduxState = () => {
const forceUpdate: any = useForceUpdate();
const store: any = useContext(ReduxStoreContext);
const state = useRef(store.getState());
const used: any = useRef({});
const handler = useMemo(() => ({
get: (target: any, name: any) => {
used.current[name] = true;
return target[name];
},
}), []);
useEffect(() => {
const callback = () => {
const nextState = store.getState();
const changed = Object.keys(used.current)
.find(key => state.current[key] !== nextState[key]);
if (changed) {
state.current = nextState;
forceUpdate();
}
};
const unsubscribe = store.subscribe(callback);
return unsubscribe;
}, []);
return new Proxy(state.current, handler);
};
A detailed explanation is written on the tutorial article. Once we have that logic we can use it like this:
const App: React.FC = () => (
<ReduxProvider store={store}>
<TodoList />
</ReduxProvider>
);
const TodoList: React.FC = () => {
const state = useReduxState();
const dispatch = useReduxDispatch();
const addTodo = useCallback((text: string) => dispatch({ type: 'ADD_TODO', text: text, }), []);
const deleteTodo = useCallback((id: number) => dispatch({ type: 'DELETE_TODO', id: id, }), []);
const editTodo = useCallback((id: number, text: string) =>
dispatch({ type: 'EDIT_TODO', id: id, text: text }), []);
const toggleTodo = useCallback((id: number) => dispatch({ type: 'TOGGLE_TODO', id: id }), []);
return (
<div>
<Header addTodo={addTodo} />
<MainSection
todos={state.todos}
deleteTodo={deleteTodo}
editTodo={editTodo}
toggleTodo={toggleTodo}
/>
</div>
);
};
Of course we can extract all the dispatch actions in a separate place, use selectors etc, but most of the functionality is similar.
Things I like: It plays well with existing Redux stores, actions and reducers.
Things I don't like: That mumbo jumbo over there looks strange. We are not sure what are the implications in terms of performance. Proxies are not available in IE11.
Phew that's it, I hope this article had demystified the usage of React hooks for managing the state. Overall I think that React hooks complement Redux in terms of suitability for purpose and convenience when handling state. Out of the three examples I kinda like the last one if as it let's me keep that redux store in place.
So what about you? Can you show some examples of scalable state management with React Hooks that you can share or you think they are better?