I'll start with a disclaimer: I am not the biggest fan of Redux.
I think Redux has some great ideas, but it tends to overcomplicate things and create more problems than it solves.
It wasn't until I started using React's useReducer
hook that I started to appreciate what a reducer does.
As I started using it more, I even started to realize that I hadn't fully grasped what a "reducer" actually was.
A reducer is basically a function that takes in a series of actions and an
initialState
, and then "reduces" them to get the current state of the application.
If this sounds familiar, it's because it's the same as the .reduce
method for arrays:
1const actions = [{2 type: 'SIGN_IN',3 payload: { name: 'Chidi' }4}, {5 type: 'SIGN_OUT'6}]78const initialState = {9 user: null,10}1112const currentState = actions.reduce((state, action) => {13// to find the current state of our app14// we take our initialState and apply all the actions on top of it15 if (action.type === 'SIGN_IN') {16 state = { ...state, user: { name: action.payload } }17 }1819 if (action.type === 'SIGN_OUT') {20 state = { ...state, user: null }21 }2223 return state;24// this is how we define our initial state in `.reduce`25}, initialState);2627// currentState would equal { user: null }
Here, currentState
would return { user: null }
, because even though we signed in, the last action we took was signing out.
If you've used Redux or useReducer
before, this will look similar even though there's no actual "reducer" in this code.
The reason this is helpful in React is because we frequently have lots of pieces of state we need to track, and they'll often change in groups.
So instead of using 4 or 5 useState
calls, we can use a reducer to create actions that describe our different state changes.
Let's start with this example of a login form:
This is a fairly standard component pattern for something like a login form. It includes:
- Tracking input values and storing them in state
- A loading state boolean to disable parts of the UI
- Error handling and error message feedback
In its current form it works fine, but if we added one or two more fields (maybe something like a "Stay Logged In" checkbox), it could start to become hard to manage.
Instead, we could try to rethink our component's state changes in terms of "actions".
When we make a request, we could call the action something like START_SIGN_IN
, and it would change our isLoading
field to true
.
When we get data back, we could make an action called SIGN_IN_SUCCESS
, which would set the isLoading
field to false
and set our data
with our request response.
Let's start by converting these "actions" into a reducer:
1// a reducer takes the current state and the next action as params2function reducer(state, action) {3 switch (action.type) {4 // we'll use this to handle our input field changes5 case 'HANDLE_CHANGE':6 // this syntax allows us to dynamically declare the key on an object7 // so for our "email" input, this would be { email: "chidi@gmail.com" }8 return { ...state, [action.name]: action.payload };9 // this is how we start our loading step10 case 'START_SIGN_IN':11 return { ...state, isLoading: true };12 case 'SIGN_IN_SUCCESS':13 return { ...state, isLoading: false, data: action.payload };14 case 'SET_ERROR_MESSAGE':15 return { ...state, isLoading: false, errorMessage: action.payload };16 // we use this default to make sure any typos don't fail silently17 default:18 throw new Error(`Action ${action.type} not found`);19 }20}
Now, we can take this reducer and pass it to the React useReducer
hook like this:
1// for our initial state, we'll use all the initial states above in our `useState()` calls2const initialState = {3 email: '',4 password: '',5 isLoading: false,6 data: null,7 errorMessage: null,8}910const LoginForm = () => {11 // we'll use our `reducer` from above12 const [state, dispatch] = useReducer(reducer, initialState);13 // ...rest of the component14}
Now we have a state
object and a dispatch
function that we can use to call our actions. So if we wanted to handle a successful sign in, we would call:
1dispatch({ type: 'SIGN_IN_SUCCESS', payload: data})
This is telling our reducer that the action we want to use is called SIGN_IN_SUCCESS
, and the payload
is any additional data we need to pass to our action. In this case, it's the response we're getting from the API.
Now we can change all of our setState
calls to dispatch
calls, and you'll see how it becomes easier to understand what's happening at each step in your app.
For example, in the handleSubmit
function, you can read the actions like instructions. First you "start sign in", and if it's successful you dispatch SIGN_IN_SUCCESS
. If something goes wrong, you set an error message.
This is not only easier for you to read, but it's much easier for your coworkers to read as well. It also forces you to think about your state not as an object that's being changed, but as a "state machine" that's changed by actions.
Here's what your final result might look like:
1const LoginForm = () => {2 const [state, dispatch] = useReducer(reducer, initialState);34 const { email, password, isLoading, errorMessage } = state;56 async function handleSubmit(e) {7 e.preventDefault();8 // before we send the request, change loading state9 dispatch({ type: 'START_SIGN_IN' });1011 try {12 // send data to your API13 const response = await api.post('/login', { email, password })1415 // if it's successful, toggle the loading boolean and set the data in state16 dispatch({ type: 'SIGN_IN_SUCCESS', payload: response.data });17 } catch (error) {18 // if something goes wrong, they shouldn't get stuck in the loading state19 dispatch({ type: 'SET_ERROR', payload: error.message });20 }21 }2223 return (24 <form onSubmit={handleSubmit}>25 <fieldset disabled={isLoading}>26 <label>27 Email28 <input29 value={email}30 onChange={e =>31 dispatch({32 type: 'HANDLE_CHANGE',33 name: 'email',34 payload: e.target.value,35 })36 }37 />38 </label>39 <label>40 Password41 <input42 type="password"43 value={password}44 onChange={e =>45 dispatch({46 type: 'HANDLE_CHANGE',47 name: 'password',48 payload: e.target.value,49 })50 }51 >52 </label>53 {errorMessage !== null && <div>{errorMessage}</div>}54 <button type="submit">Sign In</button>55 </fieldset>56 </form>57 );58};
If you want to push this even further, you could even turn this reducer into a custom hook called something like useRequest
and use it throughout your app.