The Unwrap

State First, UI Second

Model your application as a state machine. The UI renders it. Never the other way around.

The Principle

Model your application state explicitly before writing a single component. The UI is a rendering function over your state. Treat it that way.

What React Taught You to Do Wrong

React gave you useState, useEffect, useCallback, useMemo, and fifteen other hooks to orchestrate behavior inside components. So that's where the behavior went. Of course it did.

Now you have components that fetch data, handle auth, read from localStorage, call two different APIs, and manage form state. All at once. The hooks call each other. The effects trigger other effects. You added a useRef to break a cycle you don't fully understand. You call this "business logic". It isn't. It's spaghetti with a hook-shaped pasta.

The Crime Scene

I've watched this exact failure mode play out at two different companies. Different stacks, different teams, different amounts of engineering process theater. Same outcome: nobody could tell you what the app would look like after a sequence of user actions without running it. Nobody could write a test that didn't require a browser. Nobody could fix a bug without breaking two others.

Here's what the symptoms look like up close.

Flickering. Something renders in state A, flashes through state B, settles on state C. Users see the flash. You add a loading flag and the flash moves somewhere else. You file a ticket. You close the ticket. It comes back. You close it again. It is still there.

Test hell. You cannot unit test this code. It lives inside React. So you write end-to-end tests. Cypress. Playwright. Your CI takes 40 minutes. Your laptop sounds like a jet engine. You are testing whether clicking a button correctly fetches something, and it takes 40 minutes to find out. The test is flaky. You re-run it. It passes. You ship. It fails in production.

Rerenders. 40 rerenders for a simple page load. Something in localStorage triggers a context update which triggers a fetch which updates state which re-renders half the tree. You profile it in DevTools. You add a useMemo. The rerenders are now 38.

Unpredictable interactions. A legacy GraphQL API and a newer tRPC layer, both touching related objects. They update state through different paths. The UI reflects whichever one finished last. Good luck predicting what the user sees. Good luck explaining it to the PM.

The Problem Is Not React

React is fine. The problem is putting your application logic inside it.

React is a rendering library. It renders a tree of components. That is what it does well. When you put state machines, async flows, and business rules inside it, you are using a rendering library as an application framework. It fights you the whole way.

The fix is not a new framework. It is a boundary.

Your state lives outside the UI. The UI reads it and emits events. The state machine decides what happens next. React is just the last mile.

Explicit State

Oh, you want to see something beautiful? Here:

const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Equivalent smell in a Leptos/Tauri app
let user: RwSignal<Option<User>> = create_rw_signal(None);
let loading: RwSignal<bool> = create_rw_signal(false);
let error: RwSignal<Option<String>> = create_rw_signal(None);
# Equivalent smell in a backend order-flow handler
class OrderHandler:
    def __init__(self):
        self.order = None        # Order | None
        self.loading = False     # bool
        self.error = None        # str | None
# Three variables. Eight combinations. You intended three of them.

Three variables. Eight possible states. You intended three of them. The other five come free. No extra charge. A gift from boolean soup to your production environment: loading: true with a non-null user. error set while loading is still true. The null-null-null void where nothing is anything and the spinner never stops. You built a bug factory and named it UserState.

You cannot represent these invariants. You can only hope. And you will eventually stop hoping when one of those impossible states shows up in production at a time that is inconvenient for everyone.

Here's the same thing modeled correctly:

type UserState =
  | { status: 'guest' }
  | { status: 'loading' }
  | { status: 'authenticated'; user: User }
  | { status: 'error'; message: string };
enum UserState {
    Guest,
    Loading,
    Authenticated { user: User },
    Error { message: String },
}
from dataclasses import dataclass
from typing import Literal

@dataclass
class Guest: status: Literal["guest"] = "guest"

@dataclass
class Loading: status: Literal["loading"] = "loading"

@dataclass
class Authenticated:
    user: User
    status: Literal["authenticated"] = "authenticated"

@dataclass
class AuthError:
    message: str
    status: Literal["error"] = "error"

UserState = Guest | Loading | Authenticated | AuthError

Four states. Zero invalid combinations. The compiler enforces what your comments only suggested.

The UI becomes a switch statement:

function UserSection({ state }: { state: UserState }) {
  switch (state.status) {
    case 'guest':         return <LoginButton />;
    case 'loading':       return <Spinner />;
    case 'authenticated': return <Dashboard user={state.user} />;
    case 'error':         return <ErrorMessage message={state.message} />;
  }
}
fn user_section(state: &UserState) -> impl IntoView {
    match state {
        UserState::Guest => view! { <LoginButton /> },
        UserState::Loading => view! { <Spinner /> },
        UserState::Authenticated { user } => view! { <Dashboard user=user /> },
        UserState::Error { message } => view! { <ErrorMessage message=message /> },
    }
}
def handle_user_request(state: UserState) -> Response:
    match state:
        case Guest():
            return redirect("/login")
        case Loading():
            return Response(status=202, json={"status": "loading"})
        case Authenticated(user=user):
            return render_dashboard(user)
        case AuthError(message=message):
            return Response(status=401, json={"error": message})
# No null checks. No boolean conditions. The match is exhaustive.
# mypy will tell you if you forget a branch.

No conditions. No null checks. No "what if user is null and loading is false and error is also null" edge case that you discover at 2am. The type system made that state impossible.

Compose States

Real applications have multiple orthogonal state machines. Compose them.

type AuthState =
  | { status: 'guest' }
  | { status: 'authenticated'; user: User; role: 'user' | 'admin' };

type DataState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'ready'; data: T }
  | { status: 'error'; error: Error };

type AppState = {
  auth: AuthState;
  dashboard: DataState<DashboardData>;
  notifications: DataState<Notification[]>;
};
enum AuthState {
    Guest,
    Authenticated { user: User, role: Role },
}

enum DataState<T> {
    Idle,
    Loading,
    Ready { data: T },
    Error { error: AppError },
}

struct AppState {
    auth: AuthState,
    dashboard: DataState<DashboardData>,
    notifications: DataState<Vec<Notification>>,
}
from dataclasses import dataclass
from typing import Generic, TypeVar

T = TypeVar("T")

@dataclass
class GuestAuth: status: Literal["guest"] = "guest"

@dataclass
class AuthenticatedAuth:
    user: User
    role: Literal["user", "admin"]
    status: Literal["authenticated"] = "authenticated"

AuthState = GuestAuth | AuthenticatedAuth

@dataclass
class Idle: status: Literal["idle"] = "idle"

@dataclass
class DataLoading: status: Literal["loading"] = "loading"

@dataclass
class Ready(Generic[T]):
    data: T
    status: Literal["ready"] = "ready"

@dataclass
class DataError:
    error: str
    status: Literal["error"] = "error"

DataState = Idle | DataLoading | Ready[T] | DataError

@dataclass
class AppState:
    auth: AuthState
    dashboard: DataState[DashboardData]
    notifications: DataState[list[Notification]]

Your entire application fits in a single value. Serialize it, log it, replay it. When a bug report comes in, ask for the state snapshot. Reproduce it in a unit test. Fix it without opening a browser.

The UI Is the Shell

Once your state is explicit, UI components become trivial. They take state as props, return markup, emit events. No fetching. No side effects. No business logic.

// No opinions about the world. Just renders what you hand it.
function Dashboard({ state }: { state: AppState }) {
  if (state.auth.status !== 'authenticated') return null;

  return (
    <Layout user={state.auth.user}>
      <DataSection state={state.dashboard} />
      <NotificationBadge state={state.notifications} />
    </Layout>
  );
}

This component is testable without a browser. Pass a state object, assert on the output. It runs in 2ms.

It is also Storybook-friendly. Want to show the loading state? Pass { status: 'loading' }. Want the error state? Pass { status: 'error', error: new Error('timeout') }. No mocking. No network. No auth setup. Every visual variant is a value.

The Implementation Doesn't Matter

Use Zustand. Use XState. Use signals. Use useReducer. Write your own with a class and event listeners. The mechanism is irrelevant. What matters is the discipline: state lives outside components, components are pure functions over state.

// Zustand store: state lives here, not in components
const useAppStore = create<AppState & Actions>((set) => ({
  auth: { status: 'guest' },
  dashboard: { status: 'idle' },

  login: async (credentials) => {
    set({ auth: { status: 'loading' } });
    try {
      const user = await api.login(credentials);
      set({ auth: { status: 'authenticated', user, role: user.role } });
    } catch (e) {
      set({ auth: { status: 'error', message: e.message } });
    }
  },
}));

The component:

function LoginPage() {
  const auth = useAppStore((s) => s.auth);
  const login = useAppStore((s) => s.login);

  if (auth.status === 'authenticated') return <Redirect to="/dashboard" />;

  return <LoginForm onSubmit={login} loading={auth.status === 'loading'} />;
}

The component knows nothing about how login works. It reads state, calls an action. Test the store without React. Test the component without the store.

When This Doesn't Apply

Truly simple static pages. No async operations, no auth, no user-driven state transitions. A blog post with a dark mode toggle doesn't need Zustand.

Purely server-rendered apps. If your server handles all state and the client displays HTML, this is a non-problem. Lucky you.

The Receipt

Here is what not doing this costs.

40-minute CI pipelines because your logic lives inside components and components need a browser to test. A laptop that sounds like a jet engine to find out whether clicking a button fetches something correctly. Bugs you cannot reproduce without opening a browser and clicking in exactly the right order. Visual regressions caught in QA instead of tests. A codebase where nobody can confidently change a state variable without running the whole app to see what breaks.

That is the bill. It arrives slowly, then all at once, usually the week before a deadline.

"Actually..."

You're thinking

My app is too simple for this.

It isn't. If your app has a login button, you have auth state. If you fetch data, you have loading state. If anything can fail, you have error state. Every frontend app has state machines in it. You're already writing them, just badly, as boolean soup inside components.

The cost of doing this right is low: a few types, logic moved out of hooks, a store wired up. Done in a day. The cost of not doing it is 40-minute CI pipelines and a laptop that sounds like a jet engine.

You're thinking

React Query / SWR already handles loading and error states for me.

For server state, yes. These libraries are good. But server state is one slice. You still have UI state (modals, tabs, selections), auth state, and local interaction state. React Query doesn't model those. You end up with server state in React Query, everything else back in component hooks. The boundary problem remains.

You're thinking

This is just re-inventing Elm or Redux.

Elm got it right in 2012. Redux got the ceremony wrong but the architecture right. The point isn't novelty. It works, and most React codebases ignore it entirely. If you're already doing this, great. This article isn't for you.

You're thinking

What about forms? Forms have a lot of local state.

Forms are the one place where local component state is genuinely appropriate. Field values, validation messages, focused field: this is transient UI state that has no business being in your application store. Use react-hook-form or useState freely for forms. Just don't let form submission logic, API calls, or outcome handling leak back into the component. Those belong in the store.

On this page