Form with useReducer

When using useReducer we want all the state to be stored in a single object.

Therefore, we need to extend Context with the loading state:

import { useReducer } from "react";
import { initialContext, type Context } from "./shared";

type ReducerContext = Context & {
  loading: boolean;
};

const reducer = // TODO

export default function UseReducer() {
  const [context, dispatch] = useReducer(reducer, {
    ...initialContext,
    loading: false,
  });
  return (<></>);
}

An alternative would be to store the loading state in a separate useState hook. However, in this example the requirement is using only useReducer.

Updating username requires to add a new event. The reducer function will update the state based on the event:

import { useReducer } from "react";
import { initialContext, type Context } from "./shared";

type Event = { type: "update-username"; value: string };

type ReducerContext = Context & {
  loading: boolean;
};

export const reducer = (
  context: ReducerContext,
  event: Event
): ReducerContext => {
  if (event.type === "update-username") {
    return { ...context, username: event.value };
  }
  return context;
};

export default function UseReducer() {
  const [context, dispatch] = useReducer(reducer, {
    ...initialContext,
    loading: false,
  });

  return (
    <form>
      <input
        type="text"
        value={context.username}
        onChange={(e) =>
          dispatch({ type: "update-username", value: e.target.value })
        }
      />
      <button disabled={context.loading}>
        Confirm
      </button>
    </form>
  );
}

This is the code required to handle the form state and context using useReducer.

Async request with useReducer

useReducer allows implementing synchronous logic outside the component.

However, the reducer function cannot work with async requests (Promise). Therefore, we are required to implement the async request inside the component, same as we did with useState:

export default function UseReducer() {
  const [context, dispatch] = useReducer(reducer, {
    ...initialContext,
    loading: false,
  });

  // 👇 Async logic implemented inside the component
  const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    // TODO
  };

  return (
    <form onSubmit={onSubmit}>
      <input
        type="text"
        value={context.username}
        onChange={(e) =>
          dispatch({ type: "update-username", value: e.target.value })
        }
      />
      <button disabled={context.loading}>
        Confirm
      </button>
    </form>
  );
}

We also need to add a new event to update the loading state:

import { useReducer } from "react";
import { initialContext, postRequest, type Context } from "./shared";

type Event =
  | { type: "update-username"; value: string }
  | { type: "update-loading"; value: boolean };

type ReducerContext = Context & {
  loading: boolean;
};

const reducer = (context: ReducerContext, event: Event): ReducerContext => {
  if (event.type === "update-username") {
    return { ...context, username: event.value };
  } else if (event.type === "update-loading") {
    return { ...context, loading: event.value };
  }
  return context;
};

export default function UseReducer() {
  const [context, dispatch] = useReducer(reducer, {
    ...initialContext,
    loading: false,
  });

  const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    // TODO
  };

  return (
    <form onSubmit={onSubmit}>
      <input
        type="text"
        value={context.username}
        onChange={(e) =>
          dispatch({ type: "update-username", value: e.target.value })
        }
      />
      <button disabled={context.loading}>
        Confirm
      </button>
    </form>
  );
}

With this we can implement the async request the same as useState:

import { useReducer } from "react";
import { initialContext, postRequest, type Context } from "./shared";

type Event =
  | { type: "update-username"; value: string }
  | { type: "update-loading"; value: boolean };

type ReducerContext = Context & {
  loading: boolean;
};

const reducer = (context: ReducerContext, event: Event): ReducerContext => {
  if (event.type === "update-username") {
    return { ...context, username: event.value };
  } else if (event.type === "update-loading") {
    return { ...context, loading: event.value };
  }
  return context;
};

export default function UseReducer() {
  const [context, dispatch] = useReducer(reducer, {
    ...initialContext,
    loading: false,
  });

  const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    if (!context.loading) {
      dispatch({ type: "update-loading", value: true });
      await postRequest(context);
      dispatch({ type: "update-loading", value: false });
    }
  };

  return (
    <form onSubmit={onSubmit}>
      <input
        type="text"
        value={context.username}
        onChange={(e) =>
          dispatch({ type: "update-username", value: e.target.value })
        }
      />
      <button disabled={context.loading}>
        Confirm
      </button>
    </form>
  );
}

That's all. Whereas useReducer allows separating sync logic from the component, for async logic we are required to fallback to writing functions inside the component.

This breaks most of the practical benefits of useReducer.

The logic is now implemented in multiple places inside and outside the component. This makes it harder to understand the code, since we need to jump to multiple places (or even multiple files) to understand what's going on.

This causes useReducer to be more complex than useState without adding many benefits.