DEV Community

Cover image for Safe component state with useReducer and TypeScript
Łukasz Czyszczonik
Łukasz Czyszczonik

Posted on

Safe component state with useReducer and TypeScript

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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 };
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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: '' };
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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 });
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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

Oldest comments (0)