5 min read Frontend Engineering

Modeling state with more than two outcomes

A boolean is the cheapest possible way to model state, and that’s exactly the problem. It gives you two buckets: true and false. Most of the time that’s enough, a light switch really does have two states. But a lot of state that gets modeled as a boolean during a first pass is only binary-shaped for the first version of the feature, and quietly grows a third case that has nowhere to go.

The tell is when you catch yourself asking “wait, does false here mean it never happened, or that it happened and then something else happened after it?” If you have to ask that question, the boolean has already failed and the type system isn’t going to stop you from writing the bug.

An async cancellation flag that couldn’t tell two things apart

ClockWise, a Pomodoro-style timer app, has a useTimer hook that kicks off a database write (createSession) whenever the timer starts running. That write is a promise, and while it’s in flight the user is free to do other things: pause and resume, reset the timer, or start a completely new session before the first one finished saving.

The naive way to handle “ignore this write if it’s stale” is a ref that flips to true when you want to cancel:

const cancelledRef = useRef(false);

const createAndTrack = async () => {
  const promise = createSession(/* ... */);
  const session = await promise;
  if (cancelledRef.current) return; // stale, ignore
  useTimerStore.getState().setActiveSessionId(session.id);
};

This looks right and works fine for the single case it was built for: user resets the timer, cancelledRef.current = true, the in-flight write gets dropped when it resolves. The problem shows up once a second write can start before the first one finishes. Resetting the timer sets cancelledRef.current = true so write #1 gets dropped, correctly. But if the user then immediately starts a new session, that new session also needs write #1 ignored, and needs write #2 to succeed. cancelledRef has no way to express that. It’s already true from the reset, so if you don’t reset it, write #2 also gets treated as cancelled. If you do reset it back to false when the new session starts, you’ve just told write #1 “actually, you’re not cancelled anymore,” and now a stale save from a session the user already abandoned lands in the store, stomping the new session’s data. Either way, the boolean is wrong for one of the two writes, because it’s being asked to answer a question it can’t represent: cancelled by what?

The actual fix in src/hooks/useTimer.ts replaces the boolean with a monotonically increasing counter:

const sessionGenerationRef = useRef(0);

useEffect(() => {
  if (status !== 'running' || active_session_id || !user) return;

  const myGeneration = ++sessionGenerationRef.current;
  const promise = createSession(/* ... */)
    .then((session) => {
      if (sessionGenerationRef.current !== myGeneration) {
        // Timer was reset before session creation resolved -- clean up
        deleteSession(user.uid, session.id).catch(() => {});
        return session.id;
      }
      useTimerStore.getState().setActiveSessionId(session.id);
      return session.id;
    });

  return () => {
    if (sessionGenerationRef.current === myGeneration) {
      sessionGenerationRef.current++;
    }
  };
}, [status, active_session_id, user]);

Every time a session-creating write starts, it stamps itself with the current generation and bumps the counter. When the write resolves, it checks whether the generation it was stamped with still matches the current one. If a newer write has started in the meantime, the counter has moved on, and the old write knows it’s stale without anyone having to explicitly flip a flag for it. Two in-flight writes can be in two different states at once, “still current” and “superseded”, because the counter can hold more than two values. A boolean can’t.

The same shape, modeled correctly from the start

Contrast that with Booking.status in cal-agent, a scheduling app, defined in prisma/schema.prisma as an enum:

status BookingStatus @default(PENDING)

A booking is never just “confirmed: true/false.” It can be pending, confirmed, cancelled, or completed, and the app also needs to know when and by whom a cancellation happened, which is why the schema carries cancelledAt, cancelledBy, and cancellationReason alongside the enum. If someone had modeled this as isConfirmed: boolean, the first time product asked “can the host cancel a confirmed booking and have it show up differently from one that was never confirmed,” the answer would have required either a second boolean bolted on next to the first, or a rewrite. The enum didn’t need a rewrite because it already had room for the states nobody had asked for yet.

The general rule

Reach for a boolean when there really are only two states and no plausible third one is coming. The moment you’re using absence of true to mean two different things, “hasn’t happened” and “happened, then got invalidated”, stop and name the third state explicitly. An enum, a status field, or a counter that only moves forward will all do this correctly. A boolean guarded by comments explaining what false “really” means in this particular code path is a sign the type is lying about how much state it can hold.

Back to top