Write clean code in redux.
February 5, 2023Redux is a very popular package to handle global state
in the application. Redux allows you to manage
state in a single place
and keep changes in your app more predictable and traceable, making it easier to understand
the changes happening in your app. In a small and simple app you do not even need redux you can use context API.
If your application need state in many places, the app state is updated frequently, the app has a medium or large-sized codebase,
and might be worked on by many people,then you can use redux. The beauty of redux is that we can use redux in react, react native and angular.
In the past writting redux code was a too much work because of the too much boilerplate code. Thanks to redux tool kit (RTK)
writting redux code
nowadays is way more easier than redux core. So let's start by comparing thecode we write in redux core vs redux tool kit.
Setup redux without RTK. (Not to do this way)
You do not need to remember this redux core example fully but when we see these in older codebase you need to know whats going on. If you want to explore redux core more or you are working in a older codebase with redux core you can always check this docs from redux.
// add the dependencies
yarn add redux react-redux redux-thunk redux-devtools-extension
// creating the reducers
export default function cartReducer(state = initialState, action) {
switch (action.type) {
case 'cart/addItem':
return {
...state,
cart: action.payload,
};
case 'cart/clearCart':
return { ...initialState };
default:
return state;
}
}
export function addItem() {
return {
type: 'cart/addItem',
payload: {[]},
};
}
// create store
const rootReducer = combineReducers({
cart: cartReducer,
});
const store = createStore(
rootReducer,
composeWithDevTools(
// prettier-ignore
applyMiddleware(thunk),
),
);
Problem that many developers used to face using redux core:
- Complicated to configure store.
- Complex to create slice and reducer used to be big problem for me 😅.
- To do asynchronous operations like API call thunk middleware need to be passed.
So to solve the problem for the developers redux toolkit was invented. RTK wraps around the core redux package, and contains API methods and common dependencies that we think are essential for building a redux app 😲.
Let's handle the application state in go to way
. To start now you can either clone or extract templete provided from redux by
clicking here or add in existing project.
Installing the dependencies
yarn add react-redux @reduxjs/toolkit
Create Slice
createSlice
is a function where we write the state logic for a single state or a feature from bunch of reducers.
// cartSlice.js
import { createSlice } from '@reduxjs/toolkit';
const initialState = {
cart: [],
};
const cartSlice = createSlice({
name: 'cart',
initialState,
reducers: {
addItem(state, action) {
state.cart.push(action.payload);
},
deleteItem(state, action) {
state.cart.filter((cartItem) => cartItem.id !== action.payload);
},
clearCart(state) {
state.cart = [];
},
},
});
export const { addItem, deleteItem, clearCart } = cartSlice.actions;
export default cartSlice.reducer;
createSlice function accepts a configuration object with these following options:
- name:string used in action type as prefix.
- initialState:object with prop needed in the state.
- reducers:object with "case reducer" function (functions intended to handle a specific action type, equivalent to a single case statement in a switch).
In redux we used to handle the reducer with a specific action type with a switch case manually. RTK will generate the action name and type automatically from the name and method name of the reducer for example:
- addItem reducer function will handle the action type "cart/addItem".
- deleteItem reducer function will handle the action type "cart/deleteItem".
- clearItem reducer function will handle the action type "cart/clearItem".
- Each reducer will have current state and action in the argument.
Configure Store
The standard method for creating a Redux store. It uses the low-level Redux core createStore method internally, but wraps that to provide good middleware and redux devtools out of the box when we use configureStore method.
// store.js
import { configureStore } from '@reduxjs/toolkit';
import cartReducer from './features/cart/cartSlice';
const store = configureStore({
reducer: {
cart: cartReducer,
},
});
export default store;
It basically helps developers experience with doing these tasks automatically:
- Combining the slice reducers into the root reducer.
- Creating the middleware using redux-thunk as default for side effects(API calls, updating the DOM directly or Timers).
- Adding the Redux DevTools enhancer, and composing the enhancers together.
- Calling createStore method.
Provider
import App from './App.jsx';
import { Provider } from 'react-redux';
import store from './store.js';
<Provider store={store}>
<App />
</Provider>;
Provider the store to the children where we need the store to retrive the data and set the data from there.
Redux also does the perfomance optimization automatically. You can see the difference of using context and
redux by using the Profiler
tab from the react devtools not redux devtools.
Get data from store
useSelector
hook allows you to extract data from the redux store state for use in this component, using a selector function.
//cart.jsx
function Cart() {
const cart = useSelector(getCart);
// render component with data.
}
export default Cart;
// cartSlice.js
// ... existing cartSlice code
export const getCart = (state) => state.cart.cart;
useSelector hook has the state of entire store so we can isolate the getCart in the cartSlice file so that if we need the cart state in another component we can directly use that method without repeating useSelector((state) => state.cart.cart) in every where we use cart state.
Create action in the store
useDispatch
hook returns a reference to the dispatch function from the Redux store. You may use it to dispatch actions as needed,
the action that we exported from the slice.
// cart.jsx
import { useDispatch } from 'react-redux';
import { clearCart } from './cartSlice';
function Cart() {
const dispatch = useDispatch();
<div>
...
<button onClick={() => dispatch(clearCart())}>Empty cart</button>;
</div>;
}
API calls
Stop using redux if you are using redux just for data fetching. There are packages like tanstack query, RTK query for controlled data fetching and caching. However if you are using next js version 14 or later you can just use fetch api that will automatically cache the data and revalidate with some options.
// example of data fetching, caching and revalidating in next js.
const res = await fetch('https://...', { next: { tags: ['collection'] } });
Coming back to redux to perform API calls createAsyncThunk
function is used. this function accepts a redux action type string and a callback function that should always return a promise.
It generates promise lifecycle action types based on the action type prefix that you pass in, and returns a thunk action creator that will run the promise callback
and dispatch the lifecycle actions (pending, fulfilled, rejected) based on the returned promise.
// userSlice.js
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
import { userAPI } from './userAPI';
const fetchUserById = createAsyncThunk(
'users/fetchByIdStatus',
async (userId: number, thunkAPI) => {
const response = await userAPI.fetchById(userId)
return response.data
},
)
We need to call the createAsyncThunk function with the action type name 'users/fetchByIdStatus'
and callback function. The callback function is mandatory
to be asynchronous because we need those three promise states to hanlde the status, error or data according to the promise result.
Because the promise can success or fail.
// userSlice.js
const initialState = {
data: [],
status: 'idle',
error: null,
};
const usersSlice = createSlice({
name: 'users',
initialState,
reducers: {},
extraReducers: (builder) =>
builder
.addCase(fetchUserById.pending, (state) => {
state.status = 'loading';
})
.addCase(fetchUserById.fulfilled, (state, action) => {
state.data.push(action.payload);
state.status = 'success';
})
.addCase(fetchUserById.rejected, (state, action) => {
state.status = 'error';
state.error = action.error.message;
}),
});
We use extraReducers and reducers to respond to an action in the slice . It may seem they are similar but the major difference is extraReducers provides the builder API where we can add case for different type of action kind of like a switch statement. However in reducer we just pass the name of the action function and handle the case when that reducer function is dispatched. for example if the promise is pending the it sets the status to "loading" and changed accordingly to the case type.
References
If you liked it share it with your friends or colleague