useState vs useReducer
We can use the useState
to internally maintain state of a react component but as soon the internal state gets complex the number of useState
can get overwhelming. For this we can use the useReducer
.
useReducer
For the sake of learning middleware to handle async operations the example has been kept simple.
The following example is taken from the official react documentation.
It has two buttons one is used to increment and other is used to decrement. The application uses useReducer
to maintain its state.
const initialState = {count: 0};
function reducer(state, action) {
switch (action.type) {
case 'increment':
return {count: state.count + 1};
case 'decrement':
return {count: state.count - 1};
default:
throw new Error();
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<>
Count: {state.count}
<button onClick={() => dispatch({type: 'decrement'})}>-</button>
<button onClick={() => dispatch({type: 'increment'})}>+</button>
</>
);
}
Lets suppose that on the button click we want to call an api, and increment it by the value from returned the api. I know its a superficial example but the point is to demonstrate how to handle async
in useReducer
As we know that reducers are pure function and can not have any side effect so we can not perform the api call inside the useReducer
. To avoid this we can use thunks
Thunk
What is a thunk? thunk is function which is returned by another function. Its more easier to explain it via code:
function notAThunk() {
return function aThunk() {
}
}
Thunk is a piece of code encapsulated inside a function which can be called at a later time.
So instead of passing an object to the dispatch function we will pass a function. This passed function is thunk
, and will be executed at a later time by the middleware
. lets modify our code so it can handle the thunk
via middleware
. Inside Counter
we will replace:
const [state, dispatch] = useReducer(reducer, initialState);
with the following code:
const [state, orignalDispatch] = useReducer(reducer, initialState);
const dispatch = (action) => {
if (isFunction(action)) {
action(orignalDispatch);
} else {
orignalDispatch(action);
}
};
We have renamed the dispatch
returned by useReducer
to orignalDispatch
. In essence we have created a middleware. As the following code is executed whenever we dispatach an action and before the action is received by the reducer
if (isFunction(action)) {
action(orignalDispatch);
} else {
orignalDispatch(action);
}
We have created a new function called dispatch
. Our implementation first checks if the passed action
is a type of function if it is a function then it is called with orignalDispatch
as a arugment, otherwise we simply pass the action to orignalDispatch
.
Now lets simulate a fake api call:
const asynIncrementApi = () => {
return new Promise((resolve) => {
setTimeout(() => {
resolve(Math.floor(Math.random() * 10) + 1);
}, 1000);
});
};
asynIncrementApi
returns a promise with a value between 1 to 10 after atleast 1000ms
have passed.
With api in place, Lets add a new button Async Increment
to the Counter
.
Count: {state.count}
<button onClick={() => dispatch({ type: "decrement" })}>-</button>
<button onClick={() => dispatch({ type: "increment" })}>+</button>
<button>Async Increment</button>
Lets hook up Async Increment
with event handler.
<button
onClick={() => {
dispatch(() => {
asynIncrementApi().then((value) => {
console.log(value);
dispatch({
type: "increment_async",
count: value,
});
});
});
}}
>
Async Increment
</button>;
When user clicks on the Async Increment
button, its event handler is executed which makes a call to dispatch function. The action
to dispatch function is our thunk
.
In the above example following function is the thunk
() => {
asynIncrementApi().then((value) => {
dispatch({
type: "increment_async",
count: value,
});
});
};
Inside the dispatch function our check isFunction
returns true
and executes the thunk
. In this case our thunk
ignores the parameters passed to it. It is because the dispatch
is in our scope but there might be some case where the dispatch
might not be in scope (like if code is outside the component or in a separate file) so its better to pass it.
Inside our thunk
the backend api is called and as soon the promise returned by the api is resolved we dispatch a new action with type of increment_async
and count
with value returned from the backend.
This is all which is required to handle the asyc actions. Now lets update our reducer
to handle the increment_async
.
case "increment_async":
return { count: state.count + action.count };
Below is all the code and here is link to the codesandbox
import "./styles.css";
import { useReducer } from "react";
function isFunction(functionToCheck) {
return (
functionToCheck && {}.toString.call(functionToCheck) === "[object Function]"
);
}
const initialState = { count: 0 };
function reducer(state, action) {
console.log(action.type);
switch (action.type) {
case "increment":
return { count: state.count + 1 };
case "increment_async":
return { count: state.count + action.count };
case "decrement":
return { count: state.count - 1 };
default:
throw new Error();
}
}
const asynIncrementApi = () => {
return new Promise((resolve) => {
setTimeout(() => {
resolve(Math.floor(Math.random() * 10) + 1);
}, 1000);
});
};
function Counter() {
const [state, orignalDispatch] = useReducer(reducer, initialState);
const dispatch = (action) => {
if (isFunction(action)) {
action(orignalDispatch);
} else {
orignalDispatch(action);
}
};
return (
<>
Count: {state.count}
<button onClick={() => dispatch({ type: "decrement" })}>-</button>
<button onClick={() => dispatch({ type: "increment" })}>+</button>
<button
onClick={() => {
dispatch(() => {
asynIncrementApi().then((value) => {
dispatch({
type: "increment_async",
count: value
});
});
});
}}
>
Async Increment
</button>
</>
);
}
export default function App() {
return (
<div className="App">
<Counter />
</div>
);
}
Refactor the middleware into a separate hook
Lets create a new hook which encapsulates the logic of creating reducer and middleware into a separate hook.
const useThunkReducer = (reducer, initialState) => {
const [state, dispatch] = useReducer(reducer, initialState);
const enhancedDispatch = useCallback(
action => {
if (typeof action === 'function') {
action(dispatch);
} else {
dispatch(action);
}
},
[dispatch],
);
return [state, enhancedDispatch];
};
Now we use useThunkReducer
instead of useReducer
.
This example was inspired from