useState with explicit state

Our implementation since now was based on rather simple requirements.

However, in a real world scenario, requirements will change over time, fast.

Therefore, a key component of any state management solution is the ability to adapt to changes, while keeping the code bug free and maintainable.

Let's see how each solution handles some new requirements:

  • Manages possible errors in the async request
  • Provide a confirmation message when the request is successful

useState: make state explicit

Since now we only required a single loading state:

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

export default function UseState() {
  const [context, setContext] = useState<Context>(initialContext);
  const [loading, setLoading] = useState(false);

  const onUpdateUsername = (value: string) => {
    setContext({ username: value });
  };

  const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    if (!loading) {
      setLoading(true);
      await postRequest(context);
      setLoading(false);
    }
  };

  return (
    <form onSubmit={onSubmit}>
      <input
        type="text"
        value={context.username}
        onChange={(e) => onUpdateUsername(e.target.value)}
      />
      <button type="submit" disabled={loading}>
        Confirm
      </button>
    </form>
  );
}

With the new requirements we also need to track when the request errors and when it succeeds. In practice this means adding two more states: "Error" and "Complete". Furthermore, we also need to track the "Editing" state (default state).

Since only one state can be active at a time, we define all them together in a single useState:

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

type State = "Editing" | "Loading" | "Error" | "Complete";

export default function UseState() {
  const [context, setContext] = useState<Context>(initialContext);
  const [state, setState] = useState<State>("Editing");

  const onUpdateUsername = (value: string) => {
    setContext({ username: value });
  };

  /// ...

With this we can transition between each state based on the request result:

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

type State = "Editing" | "Loading" | "Error" | "Complete";

export default function UseState() {
  const [context, setContext] = useState<Context>(initialContext);
  const [state, setState] = useState<State>("Editing");

  const onUpdateUsername = (value: string) => {
    setContext({ username: value });
  };

  const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    if (state !== "Loading" && state !== "Complete") {
      setState("Loading");
      try {
        await postRequest(context);
        setState("Complete");
      } catch (_) {
        setState("Error");
      }
    }
  };

  /// ...
}

Based on the current state we can render the appropriate UI:

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

type State = "Editing" | "Loading" | "Error" | "Complete";

export default function UseState() {
  const [context, setContext] = useState<Context>(initialContext);
  const [state, setState] = useState<State>("Editing");

  const onUpdateUsername = (value: string) => {
    setContext({ username: value });
  };

  const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    if (state !== "Loading" && state !== "Complete") {
      setState("Loading");
      try {
        await postRequest(context);
        setState("Complete");
      } catch (_) {
        setState("Error");
      }
    }
  };

  // Requirement: provide a confirmation message when the request is successful
  if (state === "Complete") {
    return <p>Done</p>;
  }

  return (
    <form onSubmit={onSubmit}>
      <input
        type="text"
        value={context.username}
        onChange={(e) => onUpdateUsername(e.target.value)}
      />
      <button type="submit" disabled={state === "Loading"}>
        Confirm
      </button>

      {/* Requirement: Manages possible errors in the async request */}
      {state === "Error" && <p>Error occurred</p>}
    </form>
  );
}

Done, this works. But wait! If we look at the code, we notice that what we just did is making the state explicit. We have two useState:

  • context: Track the form values
  • state: Track the form state

This is exactly what a state machine with XState was doing since the beginning!

As the logic grows in complexity it often requires tracking more states. useState looks simple for small use cases since state is not necessary. Once the complexity grows, tracking states becomes required, and we fall back to implementing a state machine.

Let's take a look at useReducer now (spoiler: it's the same as useState).