Every Vue app I’ve worked on has these three lines:
const user = ref<User | null>(null)
const isLoading = ref(false)
const error = ref<string | null>(null)
That’s three refs: one for the data, one for the spinner and one for the error.
It has a bug, and I shipped it more than once.
The fix is a state machine. That sounds like a library you have to learn first. It’s a TypeScript type and one function.
Here’s the whole component. It loads a profile and shows it.
<script setup lang="ts">
const user = ref<User | null>(null)
const isLoading = ref(false)
const error = ref<string | null>(null)
async function load() {
isLoading.value = true
try {
user.value = await fetchUser()
} catch {
error.value = 'Could not load the profile'
} finally {
isLoading.value = false
}
}
</script>
<template>
<button @click="load">Load profile</button>
<p v-if="isLoading">Loading…</p>
<ProfileCard v-else-if="user" :user />
<p v-else-if="error">{{ error }}</p>
</template>
Load the profile once. It works.
Now the server goes down and you press the button again. The request fails and error gets set. But user still holds the old profile, and the template checks user first.
So the screen shows the old profile, and nobody sees the error.
You can swap the two v-else-if lines. Then a later success still shows the old error, because nothing clears it. You can clear error at the top of load(). Then you have to remember that in every function that touches these refs.
The real problem is the shape. Three refs with two values each give you eight combinations. Four of them make sense, two are wrong and two are unclear.
isLoading | user | error | Makes sense? |
|---|---|---|---|
| false | null | null | yes, nothing happened yet |
| true | null | null | yes, loading |
| false | set | null | yes, success |
| false | null | set | yes, error |
| false | set | set | no, which one do I show? |
| true | set | set | no |
| true | set | null | maybe |
| true | null | set | maybe |
My code can reach all eight. The bug is one of the rows that says no.
The first step is small. The component is always in exactly one situation, so I give that situation a name.
// src/features/profile/profile-machine.ts
export type ProfileState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; user: User }
| { status: 'error'; message: string }
This is a discriminated union. status tells TypeScript which of the four shapes it’s looking at.
Notice where the data went. user only exists on success. message only exists on error.
“A user and an error at the same time” isn’t a bug I have to avoid anymore. TypeScript rejects it.
I now have a list of situations. That’s the first of the three parts of a state machine:
- States are where you can be. That’s the union above.
- Events are what happened. The user clicked, the server answered, the request failed.
- Transitions say which event moves you from which state to which.
Look at the arrows that are missing. There’s no arrow from idle to success, so you can’t have a profile without loading one. There’s no FETCH arrow out of loading, so a second click during a request does nothing.
Events get a type too:
// src/features/profile/profile-machine.ts
export type ProfileEvent =
| { type: 'FETCH' }
| { type: 'RESOLVE'; user: User }
| { type: 'REJECT'; message: string }
Now the diagram becomes code. One function takes the current state and an event, and returns the next state.
// src/features/profile/profile-machine.ts
export function transition(state: ProfileState, event: ProfileEvent): ProfileState {
switch (state.status) {
case 'idle':
case 'success':
case 'error':
return event.type === 'FETCH' ? { status: 'loading' } : state
case 'loading':
if (event.type === 'RESOLVE') return { status: 'success', user: event.user }
if (event.type === 'REJECT') return { status: 'error', message: event.message }
return state
default:
return state satisfies never
}
}
Each case is a box in the diagram. Each return is an arrow. An event with no arrow returns the state unchanged.
The last line is my favorite. If I add a fifth state and forget to handle it, the build fails:
Type '{ status: "offline"; }' does not satisfy the expected type 'never'.
This file has no imports. It doesn’t use ref, watch or anything else from Vue. The whole state machine is plain TypeScript.
React developers know this function. They call it a reducer, and React ships a hook for it:
const [state, dispatch] = useReducer(transition, { status: 'idle' })
dispatch({ type: 'FETCH' })
You hand useReducer the function and a start state. You get back the current state and a dispatch function. Every call to dispatch runs your function and stores the result.
The React docs put two rules on a reducer. It has to be pure, so it sends no requests and sets no timers. And each event describes one thing that happened, not one field to set.
Vue has no useReducer. I searched Vue core and VueUse, and it isn’t there.
It doesn’t need one. React can only change state through a setter, so it needs a hook to connect a function to that setter. Vue tracks a plain assignment to a ref.
So here’s the whole composable:
// src/composables/useReducer.ts
import { shallowReadonly, shallowRef } from 'vue'
export function useReducer<S, E>(reducer: (state: S, event: E) => S, initial: S) {
const state = shallowRef(initial)
function dispatch(event: E) {
state.value = reducer(state.value, event)
}
return [shallowReadonly(state), dispatch] as const
}
Two details matter here.
shallowRef is enough. The reducer returns a new object every time, so Vue only has to watch .value.
shallowReadonly makes dispatch the only way to change the state. Assign to it directly and TypeScript reports an error:
Cannot assign to 'value' because it is a read-only property.
Could you skip the composable and write state.value = transition(state.value, event) in the component? Yes, and for one component I would. The composable is worth it when you want TypeScript to block direct assignment.
<script setup lang="ts">
import { watch } from 'vue'
import { useReducer } from '@/composables/useReducer'
import { transition } from './profile-machine'
const [state, dispatch] = useReducer(transition, { status: 'idle' })
watch(
() => state.value.status,
async (status) => {
if (status !== 'loading') return
try {
dispatch({ type: 'RESOLVE', user: await fetchUser() })
} catch {
dispatch({ type: 'REJECT', message: 'Could not load the profile' })
}
},
)
</script>
<template>
<button :disabled="state.status === 'loading'" @click="dispatch({ type: 'FETCH' })">
Load profile
</button>
<p v-if="state.status === 'loading'">Loading…</p>
<ProfileCard v-else-if="state.status === 'success'" :user="state.user" />
<p v-else-if="state.status === 'error'">{{ state.message }}</p>
</template>
The order of the v-else-if lines doesn’t matter anymore. Only one of them can be true.
The template is type-checked too. Inside the success branch, state.user exists. Use it anywhere else and vue-tsc complains:
Property 'user' does not exist on type '{ status: "error"; message: string; }'.
The reducer is pure, so it can’t fetch. So who calls the server? That was my first question.
The watch does. It reacts to the state. When the machine enters loading, it starts the request and reports back with RESOLVE or REJECT.
That also prevents duplicate requests. Click the button three times fast. The first FETCH moves the machine to loading. The next two do nothing, because loading has no FETCH arrow. The status never changes again, so the watcher runs once.
Three clicks send one request, and I didn’t write a guard for that.
Here’s everything from this post in one file. Load the profile, tick the checkbox, and load it again.
<script setup lang="ts">
import { ref, shallowReadonly, shallowRef, watch } from "vue";
type User = { name: string };
type ProfileState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; user: User }
| { status: "error"; message: string };
type ProfileEvent =
| { type: "FETCH" }
| { type: "RESOLVE"; user: User }
| { type: "REJECT"; message: string };
function transition(state: ProfileState, event: ProfileEvent): ProfileState {
switch (state.status) {
case "idle":
case "success":
case "error":
return event.type === "FETCH" ? { status: "loading" } : state;
case "loading":
if (event.type === "RESOLVE") return { status: "success", user: event.user };
if (event.type === "REJECT") return { status: "error", message: event.message };
return state;
}
}
function useReducer<S, E>(reducer: (state: S, event: E) => S, initial: S) {
const state = shallowRef(initial);
const dispatch = (event: E) => {
state.value = reducer(state.value, event);
};
return [shallowReadonly(state), dispatch] as const;
}
const failNext = ref(false);
const requests = ref(0);
const fetchUser = () =>
new Promise<User>((resolve, reject) => {
requests.value++;
setTimeout(() => (failNext.value ? reject() : resolve({ name: "Ada" })), 800);
});
const [state, dispatch] = useReducer(transition, { status: "idle" });
watch(
() => state.value.status,
async status => {
if (status !== "loading") return;
try {
dispatch({ type: "RESOLVE", user: await fetchUser() });
} catch {
dispatch({ type: "REJECT", message: "Could not load the profile" });
}
}
);
</script>
<template>
<div class="demo">
<button @click="dispatch({ type: 'FETCH' })">Load profile</button>
<label><input v-model="failNext" type="checkbox" /> the server is down</label>
<p v-if="state.status === 'loading'">Loading…</p>
<p v-else-if="state.status === 'success'">Hello, {{ state.user.name }}</p>
<p v-else-if="state.status === 'error'">{{ state.message }}</p>
<p v-else>Nothing loaded yet</p>
<pre>{{ state }}</pre>
<p>Requests sent: {{ requests }}</p>
</div>
</template>
<style scoped>
.demo {
display: grid;
gap: 0.75rem;
justify-items: start;
width: 100%;
}
.demo p {
margin: 0;
}
.demo label {
display: flex;
gap: 0.5rem;
align-items: center;
color: var(--site-color-foreground);
}
.demo pre {
margin: 0;
width: 100%;
padding: 0.75rem;
border: 1px solid var(--site-color-border);
border-radius: 0.5rem;
color: var(--site-color-foreground);
background: var(--site-color-background);
font-size: 0.8rem;
}
</style>I left the button enabled on purpose. Click it as fast as you can and watch the request counter.
The machine has no Vue in it, so the test has none either. It doesn’t mount a component, call flushPromises or fake timers.
// src/features/profile/profile-machine.test.ts
import { expect, it } from 'vitest'
import { transition, type ProfileState } from './profile-machine'
it('ignores a second FETCH while loading', () => {
const loading: ProfileState = { status: 'loading' }
expect(transition(loading, { type: 'FETCH' })).toBe(loading)
})
it('drops the old user when a reload fails', () => {
const next = transition({ status: 'loading' }, { type: 'REJECT', message: 'down' })
expect(next).toEqual({ status: 'error', message: 'down' })
})
The second test is the bug from the top of this post. It’s two lines now.
| Situation | Use |
|---|---|
| One toggle, one counter | A plain ref |
| A few states that exclude each other | A union type and a transition function |
| States inside states, states that run in parallel, timers | A statechart library like XState |
David Khourshid, who wrote XState, has a post called You don’t need a library for state machines. I agree with his split. A state machine is a function. A library is worth it once you need nested or parallel states.
I avoided state machines for years because I thought they were a tool. They’re a way to write down what you already know about your component.
You already know it can’t be loading and failed at once. Three refs can’t express that. A union can.
If you want to go one step further, the same transition function is what a model checker needs to try every order of events. I wrote about that in Formal Verification for Vue DevelopersFormal Verification for Vue Developers: No Math RequiredYour retry button has a bug that code review and tests don't catch. A model checker tries every order of events and hands you the bug as a four-step story. I show it on a Vue todo composable, with no math..
Do you have a component with three booleans that should be one status? I’d start there.