This episode covers placing logic inside the store as actions, async actions with async/await, managing loading/error/success status, and centralized error handling for clean, testable data fetching.

A good store holds not just data, but also the way that data changes. Episode 6 covers two things: placing update logic inside the store as actions, and handling asynchronous state — loading, error, and success — with a consistent, centralized pattern.
Real applications almost always communicate with a server. The async state patterns you master in this episode will be used over and over, both directly in stores and integrated with TanStack Query in episode 12.
Move update logic from components into store actions. Components simply call actions instead of rewriting business rules:
type TodoState = {
todos: string[]
addTodo: (title: string) => void
removeTodo: (id: string) => void
toggleTodo: (id: string) => void
}
export const useTodo = create<TodoState>((set, get) => ({
todos: [],
addTodo: (title) =>
set((s) => ({ todos: [...s.todos, { id: crypto.randomUUID(), title, done: false }] })),
removeTodo: (id) =>
set((s) => ({ todos: s.todos.filter((t) => t.id !== id) })),
toggleTodo: (id) =>
set((s) => ({
todos: s.todos.map((t) => (t.id === id ? { ...t, done: !t.done } : t)),
})),
}))addTodo(title) hides the detail of creating the todo object inside the store, so the component stays lean and the logic can be tested without a UI.
In the component, actions are used directly in event handlers:
const addTodo = useTodo((s) => s.addTodo)
function handleSubmit(e: FormEvent) {
e.preventDefault()
addTodo(input)
}const addTodo = useTodo((s) => s.addTodo) fetches the action function as a selector — functions are stable, so they don't trigger extra re-renders.
Actions can be asynchronous. The most common pattern: set the loading status, perform the fetch, then set data or error:
type UserState = {
user: User | null
status: 'idle' | 'loading' | 'success' | 'error'
error: string | null
fetchUser: (id: string) => Promise<void>
}
export const useUser = create<UserState>((set) => ({
user: null,
status: 'idle',
error: null,
fetchUser: async (id) => {
set({ status: 'loading', error: null })
try {
const res = await fetch(`/api/users/${id}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const user = await res.json()
set({ user, status: 'success' })
} catch (err) {
set({ status: 'error', error: (err as Error).message })
}
},
}))fetchUser(id) runs in three steps: set loading, await fetch, then set success or error. The status string makes it easy for the component to draw different UI states.
Use the store's status for conditional rendering:
const { user, status, error, fetchUser } = useUser(
useShallow((s) => ({
user: s.user,
status: s.status,
error: s.error,
fetchUser: s.fetchUser,
})),
)
if (status === 'loading') return <p>Memuat...</p>
if (status === 'error') return <p>Gagal: {error}</p>
if (!user) return <button onClick={() => fetchUser('1')}>Muat profil</button>
return <p>Halo, {user.name}</p>useShallow(...) combines several fields in a single call without triggering excessive re-renders — the pattern from episode 5. The UI now simply draws according to the state status.
Centralize error handling so every action follows the same rules:
const withStatus = async <T,>(set, promise: Promise<T>): Promise<T> => {
set({ status: 'loading', error: null })
try {
const data = await promise
set({ status: 'success' })
return data
} catch (err) {
set({ status: 'error', error: (err as Error).message })
throw err
}
}
fetchUser: async (id) => {
const user = await withStatus(set, fetch(`/api/users/${id}`).then((r) => r.json()))
set({ user })
},The helper function withStatus(set, promise) normalizes the loading/error cycle in one place. Errors are still re-thrown so the caller can handle other side effects, for example notifications or logging to an external service.
Use AbortController for requests that are no longer relevant, for example when a component unmounts:
const controller = new AbortController()
fetch('/api/users/1', { signal: controller.signal })controller.signal is passed to fetch; calling controller.abort() stops the request. The details of this pattern are discussed in episode 13 together with transient updates.
Episode 6 teaches you that a good store holds data and behavior at once: actions contain update logic, async actions manage loading/error/success, and centralized error handling keeps stores consistent and easy to test.
Key takeaways:
In the next episode we will discuss TypeScript and typing stores — create<State>()(...) with generics, defining state and action interfaces, typing middleware, and the combine pattern for slicing large stores. TypeScript is your shield in a real codebase.