The purpose of this document is to provide our contributors with optimization strategies that they can leverage to implement/refactor our current Redux code to improve our application's performance, and also provide them with Redux best practices when delving into our state storage.
Emphasize that reducers should be pure and avoid side effects.
// 🚫 Bad: Performing expensive calculations inside the reducer
const initialState = { data: [], expensiveResult: 0 };
function myReducer(state = initialState, action) {
switch (action.type) {
case 'ADD_DATA':
const newData = action.payload;
const expensiveResult = newData.reduce(
(acc, item) => acc + item.value,
0,
); // Expensive operation
return {
...state,
data: [...state.data, newData],
expensiveResult,
};
default:
return state;
}
}
// 👍 Good: Perform expensive calculations outside the reducer
const initialState = { data: [], expensiveResult: 0 };
function myReducer(state = initialState, action) {
switch (action.type) {
case 'ADD_DATA':
return {
...state,
data: [...state.data, action.payload],
};
case 'SET_EXPENSIVE_RESULT':
return {
...state,
expensiveResult: action.payload,
};
default:
return state;
}
}
// Perform the expensive calculation in an action creator or middleware
function addData(newData) {
return (dispatch, getState) => {
dispatch({ type: 'ADD_DATA', payload: newData });
const expensiveResult = newData.reduce((acc, item) => acc + item.value, 0);
dispatch({ type: 'SET_EXPENSIVE_RESULT', payload: expensiveResult });
};
}
Use selectors and reselect to memoize derived state.
import { createSelector } from 'reselect';
// State shape
const state = {
items: [
{ id: 1, value: 10 },
{ id: 2, value: 20 },
],
};
// Basic selector
const selectItems = (state) => state.items;
// Memoized selector using reselect
const selectTotalValue = createSelector([selectItems], (items) =>
items.reduce((total, item) => total + item.value, 0),
);
// Usage
const totalValue = selectTotalValue(state); // 30
Normalize state shape to avoid deeply nested structures
// 🚫 Bad: Deeply nested state shape
const state = {
users: {
byId: {
user_1a2b: {
id: 'user_1a2b',
name: 'Alice',
posts: [{ id: 'post_1a2b', title: 'Post 1' }],
},
user_2b3c: {
id: 'user_2b3c',
name: 'Bob',
posts: [{ id: 'post_2b3c', title: 'Post 2' }],
},
},
},
};
// 👍 Good: Normalized state shape
const normalizedState = {
users: {
byId: {
user_1a2b: { id: 'user_1a2b', name: 'Alice', postIds: ['post_1a2b'] },
user_2b3c: { id: 'user_2b3c', name: 'Bob', postIds: ['post_2b3c'] },
},
allIds: ['user_1a2b', 'post_2b3c'],
},
posts: {
byId: {
post_1a2b: { id: 'post_1a2b', title: 'Post 1' },
post_2b3c: { id: 'post_2b3c', title: 'Post 2' },
},
allIds: ['post_1a2b', 'post_2b3c'],
},
};
Combine multiple actions into a single action when possible.
// 🚫 Bad: Dispatching multiple actions separately
function updateUserAndPosts(user, posts) {
return (dispatch) => {
dispatch({ type: 'UPDATE_USER', payload: user });
dispatch({ type: 'UPDATE_POSTS', payload: posts });
};
}
// 👍 Good: Combining actions into a single action
function updateUserAndPosts(user, posts) {
return {
type: 'UPDATE_USER_AND_POSTS',
payload: { user, posts },
};
}
// Reducer handling the combined action
function rootReducer(state = initialState, action) {
switch (action.type) {
case 'UPDATE_USER_AND_POSTS':
return {
...state,
user: action.payload.user,
posts: action.payload.posts,
};
default:
return state;
}
}
Ensure immutability to prevent unnecessary re-renders.
// 🚫 Bad: Mutating state directly
const initialState = { items: [] };
function myReducer(state = initialState, action) {
switch (action.type) {
case 'ADD_ITEM':
state.items.push(action.payload); // Direct mutation
return state;
default:
return state;
}
}
// 👍 Good: Using immutable updates
const initialState = { items: [] };
function myReducer(state = initialState, action) {
switch (action.type) {
case 'ADD_ITEM':
return {
...state,
items: [...state.items, action.payload], // Immutable update
};
default:
return state;
}
}
As detailed in the Official Redux Style Guide below is a lists of recommended patterns, best practices, and suggested approaches for writing Redux applications.
These patters are split into 3 categories of rules
- Priority A: Essential - These rules help prevent errors, so learn and abide by them at all costs.
- Priority B: Strongly Recommended - These rules have been found to improve readability and/or developer experience in most projects.
- Priority C: Recommended
Mutating state is the most common cause of bugs in Redux applications.
// 🚫 Bad: Mutating state directly
const initialState = { items: [] };
function myReducer(state = initialState, action) {
switch (action.type) {
case 'ADD_ITEM':
state.items.push(action.payload); // Direct mutation
return state;
default:
return state;
}
}
// 👍 Good: Using immutable updates
const initialState = { items: [] };
function myReducer(state = initialState, action) {
switch (action.type) {
case 'ADD_ITEM':
return {
...state,
items: [...state.items, action.payload], // Immutable update
};
default:
return state;
}
}
Reducers should only depend on their state and action arguments.
// 🚫 Bad: Performing side effects in reducers
function myReducer(state = initialState, action) {
switch (action.type) {
case 'FETCH_DATA':
fetch('/api/data') // Side effect
.then((response) => response.json())
.then((data) => {
state.data = data; // Direct mutation
});
return state;
default:
return state;
}
}
// 👍 Good: Handling side effects outside reducers
function myReducer(state = initialState, action) {
switch (action.type) {
case 'SET_DATA':
return {
...state,
data: action.payload,
};
default:
return state;
}
}
function fetchData() {
return (dispatch) => {
fetch('/api/data')
.then((response) => response.json())
.then((data) => {
dispatch({ type: 'SET_DATA', payload: data });
});
};
}
Avoid putting non-serializable values such as Promises, Symbols, Maps/Sets, functions, or class instances into the Redux store state or dispatched actions.
// 🚫 Bad: Storing non-serializable values in state
const initialState = { data: new Map() };
function myReducer(state = initialState, action) {
switch (action.type) {
case 'SET_DATA':
return {
...state,
data: action.payload, // payload is a Map
};
default:
return state;
}
}
// 👍 Good: Storing serializable values in state
const initialState = { data: {} };
function myReducer(state = initialState, action) {
switch (action.type) {
case 'SET_DATA':
return {
...state,
data: action.payload, // payload is a plain object
};
default:
return state;
}
}
Reducers should only depend on their state and action arguments.
// store.js
import { configureStore } from '@reduxjs/toolkit';
import rootReducer from './reducers';
const store = configureStore({ reducer: rootReducer });
export default store;
// index.js
import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import store from './store';
import App from './App';
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('root'),
);
Redux Toolkit simplifies your logic and ensures that your application is set up with good defaults.
import { createSlice, configureStore } from '@reduxjs/toolkit';
const counterSlice = createSlice({
name: 'counter',
initialState: 0,
reducers: {
increment: (state) => state + 1,
decrement: (state) => state - 1,
},
});
const store = configureStore({
reducer: {
counter: counterSlice.reducer,
},
});
export const { increment, decrement } = counterSlice.actions;
export default store;
Immer allows you to write simpler immutable updates using "mutative" logic.
import produce from 'immer';
const initialState = { items: [] };
const myReducer = (state = initialState, action) => {
switch (action.type) {
case 'ADD_ITEM':
return produce(state, (draftState) => {
draftState.items.push(action.payload);
});
default:
return state;
}
};
Co-locating logic for a given feature in one place typically makes it easier to maintain that code.
src/
features/
counter/
counterSlice.js
CounterComponent.js
Try to put as much of the logic for calculating a new state into the appropriate reducer.
// 🚫 Bad: Logic in action creators
function addItem(item) {
return (dispatch, getState) => {
const state = getState();
if (!state.items.includes(item)) {
dispatch({ type: 'ADD_ITEM', payload: item });
}
};
}
// 👍 Good: Logic in reducers
const initialState = { items: [] };
const myReducer = (state = initialState, action) => {
switch (action.type) {
case 'ADD_ITEM':
if (!state.items.includes(action.payload)) {
return {
...state,
items: [...state.items, action.payload],
};
}
return state;
default:
return state;
}
};
Minimize the use of "blind spreads/returns".
// 🚫 Bad: Blind spread
function myReducer(state = initialState, action) {
switch (action.type) {
case 'SET_DATA':
return {
...state,
...action.payload, // Blind spread
};
default:
return state;
}
}
// 👍 Good: Explicitly define state shape
function myReducer(state = initialState, action) {
switch (action.type) {
case 'SET_DATA':
return {
...state,
data: action.payload.data,
timestamp: action.payload.timestamp,
};
default:
return state;
}
}
Name these keys after the data that is kept inside.
import { combineReducers } from 'redux';
const rootReducer = combineReducers({
users: usersReducer,
posts: postsReducer,
});
export default rootReducer;
Define and name root state slices based on the major data types or areas of functionality.
const rootReducer = combineReducers({
auth: authReducer,
posts: postsReducer,
users: usersReducer,
ui: uiReducer,
});
export default rootReducer;
Treat reducers as "state machines".
const initialState = { status: 'idle', data: null };
function myReducer(state = initialState, action) {
switch (action.type) {
case 'FETCH_START':
if (state.status === 'idle') {
return { ...state, status: 'loading' };
}
return state;
case 'FETCH_SUCCESS':
if (state.status === 'loading') {
return { ...state, status: 'succeeded', data: action.payload };
}
return state;
case 'FETCH_FAILURE':
if (state.status === 'loading') {
return { ...state, status: 'failed' };
}
return state;
default:
return state;
}
}
Prefer storing data in a "normalized" form.
// Normalized state shape
const normalizedState = {
users: {
byId: {
1: { id: 1, name: 'Alice', postIds: [1] },
2: { id: 2, name: 'Bob', postIds: [2] },
},
allIds: [1, 2],
},
posts: {
byId: {
1: { id: 1, title: 'Post 1' },
2: { id: 2, title: 'Post 2' },
},
allIds: [1, 2],
},
};
Derive additional values from the state as needed.
import { createSelector } from 'reselect';
const selectTodos = (state) => state.todos;
const selectCompletedTodos = createSelector([selectTodos], (todos) =>
todos.filter((todo) => todo.completed),
);
Treat actions more as "describing events that occurred".
// 🚫 Bad: Setter action
const setUserName = (name) => ({
type: 'SET_USER_NAME',
payload: name,
});
// 👍 Good: Event action
const userNameUpdated = (name) => ({
type: 'USER_NAME_UPDATED',
payload: name,
});
Actions should be written with meaningful, informative, descriptive type fields.
// 🚫 Bad: Generic action name
const setData = (data) => ({
type: 'SET_DATA',
payload: data,
});
// 👍 Good: Descriptive action name
const userDataFetched = (data) => ({
type: 'USER_DATA_FETCHED',
payload: data,
});
Many reducer functions can handle the same action separately.
const userReducer = (state = {}, action) => {
switch (action.type) {
case 'USER_LOGGED_IN':
return { ...state, user: action.payload };
default:
return state;
}
};
const uiReducer = (state = {}, action) => {
switch (action.type) {
case 'USER_LOGGED_IN':
return { ...state, isLoggedIn: true };
default:
return state;
}
};
Prefer dispatching a single "event"-type action.
// 🚫 Bad: Dispatching multiple actions
function loginUser(user) {
return (dispatch) => {
dispatch({ type: 'SET_USER', payload: user });
dispatch({ type: 'SET_LOGGED_IN', payload: true });
};
}
// 👍 Good: Dispatching a single action
function loginUser(user) {
return {
type: 'USER_LOGGED_IN',
payload: user,
};
}
Decide what state should live in the Redux store and what should stay in component state.
// Local component state for form inputs
function LoginForm() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const handleSubmit = () => {
// Dispatch action to update Redux store
dispatch(loginUser({ username, password }));
};
return (
<form onSubmit={handleSubmit}>
<input value={username} onChange={(e) => setUsername(e.target.value)} />
<input value={password} onChange={(e) => setPassword(e.target.value)} />
<button type="submit">Login</button>
</form>
);
}
Prefer using the React-Redux hooks API.
import { useSelector, useDispatch } from 'react-redux';
function Counter() {
const count = useSelector((state) => state.counter);
const dispatch = useDispatch();
return (
<div>
<span>{count}</span>
<button onClick={() => dispatch(increment())}>Increment</button>
</div>
);
}
Prefer having more UI components subscribed to the Redux store and reading data at a more granular level.
function UserList() {
const userIds = useSelector((state) => state.users.allIds);
return (
<ul>
{userIds.map((id) => (
<UserListItem key={id} userId={id} />
))}
</ul>
);
}
function UserListItem({ userId }) {
const user = useSelector((state) => state.users.byId[userId]);
return <li>{user.name}</li>;
}
Text
const mapDispatchToProps = {
increment,
decrement,
};
export default connect(null, mapDispatchToProps)(CounterComponent);
Prefer calling useSelector many times and retrieving smaller amounts of data.
function TodoList() {
const todos = useSelector((state) => state.todos);
const filter = useSelector((state) => state.visibilityFilter);
const visibleTodos = todos.filter((todo) => {
if (filter === 'SHOW_COMPLETED') {
return todo.completed;
}
if (filter === 'SHOW_ACTIVE') {
return !todo.completed;
}
return true;
});
return (
<ul>
{visibleTodos.map((todo) => (
<li key={todo.id}>{todo.text}</li>
))}
</ul>
);
}
Use a static type system like TypeScript.
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface CounterState {
value: number;
}
const initialState: CounterState = {
value: 0,
};
const counterSlice = createSlice({
name: 'counter',
initialState,
reducers: {
increment(state) {
state.value += 1;
},
decrement(state) {
state.value -= 1;
},
incrementByAmount(state, action: PayloadAction<number>) {
state.value += action.payload;
},
},
});
export const { increment, decrement, incrementByAmount } = counterSlice.actions;
export default counterSlice.reducer;
Configure your Redux store to enable debugging with the Redux DevTools Extension.
import { configureStore } from '@reduxjs/toolkit';
import rootReducer from './reducers';
const store = configureStore({
reducer: rootReducer,
devTools: process.env.NODE_ENV !== 'production',
});
export default store;
Prefer using plain JavaScript objects and arrays for your state tree.
const initialState = {
users: [],
posts: [],
};
function myReducer(state = initialState, action) {
switch (action.type) {
case 'ADD_USER':
return {
...state,
users: [...state.users, action.payload],
};
case 'ADD_POST':
return {
...state,
posts: [...state.posts, action.payload],
};
default:
return state;
}
}
Use the "domain/eventName" convention for readability.
const ADD_TODO = 'todos/addTodo';
const INCREMENT = 'counter/increment';
Prefer using FSA-formatted actions for consistency.
const addTodo = (text) => ({
type: 'todos/addTodo',
payload: text,
});
const fetchTodosFailure = (error) => ({
type: 'todos/fetchTodosFailure',
payload: error,
error: true,
});
Text
const addTodo = (text) => ({
type: 'ADD_TODO',
payload: text,
});
const increment = () => ({
type: 'INCREMENT',
});
Use RTK Query as the default approach for data fetching and caching.
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
const api = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
endpoints: (builder) => ({
getTodos: builder.query({
query: () => 'todos',
}),
}),
});
export const { useGetTodosQuery } = api;
Use the Redux thunk middleware for imperative logic.
// Thunk for fetching data
const fetchData = () => {
return async (dispatch) => {
dispatch({ type: 'FETCH_START' });
try {
const response = await fetch('/api/data');
const data = await response.json();
dispatch({ type: 'FETCH_SUCCESS', payload: data });
} catch (error) {
dispatch({ type: 'FETCH_FAILURE', error });
}
};
};
Move complex synchronous or async logic outside components, usually into thunks.
// Thunk for handling complex logic
const complexLogic = () => {
return (dispatch, getState) => {
const state = getState();
// Perform complex logic here
dispatch({ type: 'COMPLEX_LOGIC_DONE' });
};
};
Use memoized selector functions for reading store state whenever possible.
import { createSelector } from 'reselect';
const selectTodos = (state) => state.todos;
const selectVisibleTodos = createSelector(
[selectTodos, (state) => state.visibilityFilter],
(todos, filter) => {
switch (filter) {
case 'SHOW_COMPLETED':
return todos.filter((todo) => todo.completed);
case 'SHOW_ACTIVE':
return todos.filter((todo) => !todo.completed);
default:
return todos;
}
},
);
Prefix selector function names with the word "select".
const selectTodos = (state) => state.todos;
const selectVisibleTodos = createSelector(
[selectTodos, (state) => state.visibilityFilter],
(todos, filter) => {
switch (filter) {
case 'SHOW_COMPLETED':
return todos.filter((todo) => todo.completed);
case 'SHOW_ACTIVE':
return todos.filter((todo) => !todo.completed);
default:
return todos;
}
},
);
Most form state should not go in Redux.
// Local component state for form inputs
function LoginForm() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const handleSubmit = () => {
// Dispatch action to update Redux store
dispatch(loginUser({ username, password }));
};
return (
<form onSubmit={handleSubmit}>
<input value={username} onChange={(e) => setUsername(e.target.value)} />
<input value={password} onChange={(e) => setPassword(e.target.value)} />
<button type="submit">Login</button>
</form>
);
}
-
Simplified Code: RTK provides utilities like createSlice, createAsyncThunk, and configureStore that reduce boilerplate and simplify Redux logic.
-
Built-in Best Practices: RTK includes best practices by default, such as enabling the Redux DevTools Extension and using Immer for immutable updates.
-
Improved Performance: RTK helps prevent common performance pitfalls by encouraging the use of memoized selectors and normalized state.
npm install @reduxjs/toolkit
createSlice allows us to simplify reducers and actions.
import { createSlice } from '@reduxjs/toolkit';
const counterSlice = createSlice({
name: 'counter',
initialState: 0,
reducers: {
increment: (state) => state + 1,
decrement: (state) => state - 1,
},
});
export const { increment, decrement } = counterSlice.actions;
export default counterSlice.reducer;
Example of our current reducer in engine:
import Engine from '../../core/Engine';
const initialState = {
backgroundState: {},
};
const engineReducer = (state = initialState, action) => {
switch (action.type) {
case 'INIT_BG_STATE':
return { backgroundState: Engine.state };
case 'UPDATE_BG_STATE': {
const newState = { ...state };
newState.backgroundState[action.key] = Engine.state[action.key];
return newState;
}
default:
return state;
}
};
export default engineReducer;
How it would look after converting it to RTK:
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { EngineState } from '../../../Engine';
export interface updateEngineAction {
key: string;
engineState: EngineState;
}
const initialState = {
backgroundState: {} as any,
};
// Redux Toolkit's createReducer and createSlice automatically use Immer internally
// to let us write simpler immutable update logic using "mutating" syntax.
// This helps simplify most reducer implementations.
const engineSlice = createSlice({
name: 'engine',
initialState,
reducers: {
initializeEngineState: (state, action: PayloadAction<EngineState>) => {
state.backgroundState = action.payload;
},
updateEngineState: (state, action: PayloadAction<updateEngineAction>) => {
state.backgroundState[action.payload.key] =
action.payload.engineState[action.payload.key as keyof EngineState];
},
},
});
export const actions = engineSlice.actions;
export const reducer = engineSlice.reducer;
createAsyncThunk allows us to handle async logic more effectively.
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
export const fetchUserById = createAsyncThunk(
'users/fetchByIdStatus',
async (userId, thunkAPI) => {
const response = await fetch(`/api/user/${userId}`);
return response.json();
},
);
const userSlice = createSlice({
name: 'user',
initialState: { entities: {}, loading: 'idle' },
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchUserById.pending, (state) => {
state.loading = 'loading';
})
.addCase(fetchUserById.fulfilled, (state, action) => {
state.loading = 'idle';
state.entities[action.payload.id] = action.payload;
});
},
});
export default userSlice.reducer;
Set up the store with good defaults and middleware.
import { configureStore } from '@reduxjs/toolkit';
import rootReducer from './reducers';
const store = configureStore({
reducer: rootReducer,
});
export default store;
Create a slice for each feature to encapsulate its state and reducers.
import { createSlice } from '@reduxjs/toolkit';
const todosSlice = createSlice({
name: 'todos',
initialState: [],
reducers: {
addTodo: (state, action) => {
state.push(action.payload);
},
toggleTodo: (state, action) => {
const todo = state.find((todo) => todo.id === action.payload);
if (todo) {
todo.completed = !todo.completed;
}
},
},
});
export const { addTodo, toggleTodo } = todosSlice.actions;
export default todosSlice.reducer;
Use createAsyncThunk to handle asynchronous actions.
Use configureStore to set up the Redux store with good defaults and middleware.
Use createEntityAdapter to manage normalized state.
import { createSlice, createEntityAdapter } from '@reduxjs/toolkit';
const todosAdapter = createEntityAdapter();
const todosSlice = createSlice({
name: 'todos',
initialState: todosAdapter.getInitialState(),
reducers: {
addTodo: todosAdapter.addOne,
updateTodo: todosAdapter.updateOne,
removeTodo: todosAdapter.removeOne,
},
});
export const { addTodo, updateTodo, removeTodo } = todosSlice.actions;
export default todosSlice.reducer;
Use createEntityAdapter to manage normalized state.
import { createSlice, createEntityAdapter } from '@reduxjs/toolkit';
const todosAdapter = createEntityAdapter();
const todosSlice = createSlice({
name: 'todos',
initialState: todosAdapter.getInitialState(),
reducers: {
addTodo: todosAdapter.addOne,
updateTodo: todosAdapter.updateOne,
removeTodo: todosAdapter.removeOne,
},
});
export const { addTodo, updateTodo, removeTodo } = todosSlice.actions;
export default todosSlice.reducer;