In React, we use Context to share the same information across multiple components.
However, when multiple contexts are needed throughout the entire application, not only does code complexity increase, but frequent updates can also cause performance degradation.
How do frequent updates affect context?
Context re-renders all child components whenever the Provider changes. Therefore, frequent updates can cause performance degradation.
To solve this, we can use various state management libraries.
In this post, I want to explore Redux, which is one of the most popular libraries among the various options.
TL;DR
- Understand how redux works.
- Learn how to use redux better.
Redux refers to a pattern and library for managing and updating application state using events called "actions".
According to the official documentation, it has the following characteristics:
First, let's learn about Redux's core concepts
An object that stores the state of a Redux application.
You can create a store by passing a reducer.
import { configureStore } from '@reduxjs/toolkit'
const store = configureStore({ reducer: counterReducer })
console.log(store.getState())
// {value: 0}An object that represents "something happened" in the application.
const counterIncreaseAction = {
type: 'counter/increment',
payload: 1
}The only way to change state in Redux is to create an action object and call store.dispatch() to pass the action object.
store.dispatch({ type: 'counter/increment' })
console.log(store.getState())
// {value: 1}A reducer is a function that takes state and action as arguments, updates the state, and returns a new state. You can think of it as an event listener that handles events based on the type of the action object.
const initialState = { value: 0 }
function counterReducer(state = initialState, action) {
switch (action.type) {
case 'counter/increment':
return {
...state,
value: state.value + 1
}
default:
return state
}
}Important Notes
Reducers must follow these rules:
- They should only calculate the new state value based on the
stateandactionarguments.- They are not allowed to modify the existing state. Instead, they must make immutable updates by copying the existing state and making changes to the copied values.
- They must not do any asynchronous logic, calculate random values, or cause other "side effects".
Based on these concepts, Redux's operation can be understood as follows:
(1) The store is created through the root reducer.
(2) When changes occur in the application, an action is dispatched to the store.
(3) The store calls the reducer and passes the current state and action as arguments.
(4) The reducer updates the state according to the action's type and returns the new state.
(5) The store saves the new state returned by the reducer.
(6) When the store's state changes, the components that subscribe to it re-render.To use redux in a React application, we use the react-redux library.
npm install react-redux// store/index.js
import { createStore } from 'redux'
const counterReducer = (state = { counter: 0 }, action) => {
if (action.type === 'increment') {
return {
counter: state.counter + 1
}
}
if (action.type === 'decrement') {
return {
counter: state.counter - 1
}
}
return state
}
const store = createStore(counterReducer)
export default storeimport React from 'react';
import ReactDOM from 'react-dom/client';
import { Provider } from 'react-redux';
import store from './store';
import './index.css';
import App from './App';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<Provider store={store}>
<App />
</Provider>
);import { useSelector, useDispatch } from 'react-redux';
const Counter = () => {
const counter = useSelector(state => state.counter);
const dispatch = useDispatch();
const incrementHandler = () => {
dispatch({ type: 'increment' });
};
const decrementHandler = () => {
dispatch({ type: 'decrement' });
};
return (
<main>
<div>{ counter }</div>
<div>
<button onClick={incrementHandler}>Increment</button>
<button onClick={decrementHandler}>Decrement</button>
</div>
</main>
)
}While redux allows us to manage multiple states in one place, as the application grows larger, state management becomes more complex and it becomes difficult to manage each reducer's identifiers.
The library that makes this easier is redux-toolkit.
Let's look at the main methods of redux-toolkit:
reducers parameter automatically receive the latest state.
createStore internally to provide a better DX when creating state.The code written above can be refactored using redux-toolkit as follows:
// store/index.js
import { createSlice, configureStore } from '@reduxjs/toolkit'
const initialState = { counter: 0, showCounter: true }
const counterSlice = createSlice({
name: 'counter',
initialState,
reducers: {
increment(state) {
state.counter++
},
decrement(state) {
state.counter--
},
increase(state, action) {
state.counter += action.payload
},
toggle(state) {
state.showCounter = !state.showCounter
}
}
})
const store = configureStore({
reducer: counterSlice.reducer
})
export const counterActions = counterSlice.actions
export default storeIn components, it's used as follows:
import { useSelector, useDispatch } from 'react-redux';
import { counterActions } from '../store';
const Counter = () => {
const counter = useSelector(state => state.counter);
const dispatch = useDispatch();
const incrementHandler = () => {
dispatch(counterActions.increment());
};
const increaseHandler = () => {
dispatch(counterActions.increase(5));
};
const decrementHandler = () => {
dispatch(counterActions.decrement());
};
return (
<main>
<div>{ counter }</div>
<div>
<button onClick={incrementHandler}>Increment</button>
<button onClick={increaseHandler}>Increment by 5</button>
<button onClick={decrementHandler}>Decrement</button>
</div>
</main>
)
}Reference Links
As mentioned above, one of Redux's core concepts, Reducer, has the important note that side effects should never be included.
This means that Redux should only be used to calculate new state based on state and action.
Redux doesn't need to know about any asynchronous logic. The only role of a reducer is to change state using the received action.
However, in real-world applications, we often need to handle side effects like asynchronous logic. How can we solve this in Redux?
When handling side effects inside components, you must pay attention to the following:
Let's learn how to handle side effects inside components through an example that integrates a shopping cart with a backend (firebase).
// src/App.js
import { useSelector } from 'react-redux';
const cart = useSelector((state) => state.cart);
let isInitial = true;
useEffect(() => {
const sendCartData = async () => {
dispatch(uiActions.showNotification({
status: 'pending',
title: 'Sending...',
message: 'Sending cart data!'
}));
// Logic for handling side effects
const response = await fetch('FIREBASE_URL', {
method: 'PUT',
body: JSON.stringify(cart)
})
if (!response.ok) {
throw new Error('Sending cart data failed.');
}
dispatch(uiActions.showNotification({
status: 'success',
title: 'Success!',
message: 'Sent cart data successfully!'
}));
}
if (isInitial) {
isInitial = false;
return;
}
sendCartData().catch(error => {
dispatch(uiActions.showNotification({
status: 'error',
title: 'Error!',
message: 'Sending cart data failed!'
}));
})
}
, [cart, dispatch]);Since the http request was made in the component, the reducer can be separated from side effects and ensure immutability.
Through redux toolkit, we automatically get action creators and import them to create action objects to dispatch.
Alternatively, we can create thunks.
In programming, a thunk means "code that does delayed work". In other words, it's a simple function that delays work until other work is completed. Using this, we can write code to execute some logic later instead of immediately.
This means we can execute other code before dispatching the actual action object.
// src/App.js
import { sendCartData } from './store/cartActions';
let isInitial = true;
useEffect(() => {
if (isInitial) {
isInitial = false;
return;
}
dispatch(sendCartData(cart));
}, [cart, dispatch]);The thunk that dispatches the actual action object can be written as follows:
// src/store/cartActions.js
// Create a thunk that exists outside the reducer function to perform side effects
export const sendCartData = (cart) => {
return async (dispatch) => {
dispatch(uiActions.showNotification({
status: 'pending',
title: 'Sending...',
message: 'Sending cart data!'
}));
// Logic for handling side effects
const sendRequest = async () => {
const response = await fetch('FIREBASE_URL', {
method: 'PUT',
body: JSON.stringify(cart)
})
if (!response.ok) {
throw new Error('Sending cart data failed.');
}
}
try {
await sendRequest()
dispatch(uiActions.showNotification({
status: 'success',
title: 'Success!',
message: 'Sent cart data successfully!'
}));
} catch (error) {
dispatch(uiActions.showNotification({
status: 'error',
title: 'Error!',
message: 'Sending cart data failed!'
}));
}
}
};Why use thunks?
Redux reducers should not contain side effects. However, real applications require logic that includes side effects. Thunks can be used to separate this logic from the UI layer.
I've learned about redux's operating principles and core concepts, as well as how to use redux better. I learned that good layer separation is important not only for implementing business logic but also for state management to write better code. I should try using redux in my next side project!
What I learned through this post
- Learned about redux's operating principles.
- Learned about redux's core concepts.
- Learned how to handle side effects in redux.
- Components
- Action creators (thunks)
References