Photo by Nelly Antoniadou on Unsplash
Using multiple useState's to control component state is a common practice in React codebases, but it can result in unexpected behavior, such as a forever loading component or a disabled submit button.
Let's see what such components usually look like:
...
const [email, setEmail] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
async function handleEmailSend() {
setLoading(true);
setSuccess(null);
setErrorMessage(null);
if (!isEmailValid(email)) {
setLoading(false);
return setErrorMessage('Invalid email');
}
try {
const response = await sendToAPI();
setLoading(false);
setSuccess(response);
setEmail('');
} catch (error) {
if (error instanceof Error) {
setErrorMessage(error.message);
}
setLoading(false);
}
}
You can imagine that one missing setState can make an invalid UI state that we do not want.
We can use useReducer
to make the code much cleaner and less error-prone.
Letβs start by defining all the different possible component states we want to handle:
type Typing = { type: 'typing'; email: string };
type Fetching = { type: 'fetching' };
type FetchSuccess = { type: 'success'; message: string };
type FetchFailed = { type: 'failed'; error: string };
We used type intersection to distinguish the correct type based on the type property. Our reducer state will be a union of all defined above types plus email filed.
type ResponseState = Typing | Fetching | FetchSuccess | FetchFailed;
type BaseState = {
email: string;
};
type State = BaseState & ResponseState;
The reducer requires action types, which typically include a "type" property. We can utilize the valid UI states that we've already defined, eliminating the need for duplicating code when defining our actions.
type Action = Fetching | Typing | FetchSuccess | FetchFailed;
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'typing':
case 'fetching':
case 'failed': {
return { ...state, ...action };
}
case 'success': {
return { ...action, email: '' };
}
}
}
The reducer is straightforward, thanks to the reuse of types from the reducer state for the actions, and the fact that their shape aligns with the reducer action pattern. The only exception is the success action, where we also clear the email input value. By connecting the reducer actions and state in this manner, unexpected states should no longer be an issue.
Let's see how we can simplify our handlers:
async function handleEmailSend() {
if (!isValidElement(state.email)) {
return dispatch({ type: 'failed', error: 'Invalid email'
});
}
try {
dispatch({ type: 'fetching' });
const response = await sendToAPI();
dispatch({ type: 'success', message: response });
} catch (error) {
if (error instanceof Error) {
dispatch({ type: 'failed', error: error.message });
}
}
}
The component render is also pretty clear and easy to understand:
<div>
{state.type === 'failed' && <ErrorMessage error={state.error} />}
{state.type === 'success' && <Success message={state.message} />}
</div>
The above example dismissed the invalid states and made our component safe, so we can assume that we archive the goal!
Please let me know in the comments what you think about that kind of reducer typing and if you have used it before!
If you want to explore the full example, here is the live example
Top comments (0)