This article is published in English.
A Predictable React Baseline with TypeScript, Zustand and Typed Services
A small React, TypeScript and Zustand skeleton that separates stores, typed API services and components, plus conventions for structure, async state and testing.
The first screen of a new project is tiny, but the decisions behind it, where state lives, how data is fetched and how strictly things are typed, shape everything that follows. A small "Hello App" is the place to settle them. This walkthrough builds one with React, TypeScript and Zustand, explains what each piece of the skeleton is responsible for, and turns the underlying conventions into rules you can apply as the codebase grows.
Why React, TypeScript and Zustand together
Each tool covers a different concern:
- React provides declarative UI, a mature ecosystem and strong tooling, and composition keeps components readable.
- TypeScript catches mistakes at compile time, makes refactoring safer and turns component props and store shapes into self-documenting contracts.
- Zustand is a small state library with very little ceremony: a store is a hook, the data model is plain objects and functions, and components listen only to the slices they read.
Start with those three plus a router, and add a library only when a concrete need appears.
The skeleton: store, service and component
The example below is shown as one listing but represents three files, each with a single job. store/counterStore.ts defines a typed Zustand store holding a count, a loading flag, a synchronous increment action and an async loadInitial action. services/counterApi.ts wraps the HTTP call behind a typed function that throws on a non-OK response. App.tsx reads individual values through selectors and triggers the initial load in an effect.
Notice three things. The store never calls fetch directly; it delegates to the service, so the network layer can be swapped or mocked independently. The finally block guarantees that loading is reset even when the request fails. And the component subscribes to each field with its own selector, so it re-renders only when a value it actually uses changes.
// store/counterStore.ts
import { create } from "zustand"
type CounterState = {
count: number
loading: boolean
increment: () => void
loadInitial: () => Promise<void>
}
export const useCounter = create<CounterState>((set, get) => ({
count: 0,
loading: false,
increment: () => set({ count: get().count + 1 }),
loadInitial: async () => {
set({ loading: true })
try {
const value = await fetchInitialCount()
set({ count: value })
} finally {
set({ loading: false })
}
},
}))
// services/counterApi.ts
export type CounterResponse = { value: number }
export async function fetchInitialCount(): Promise<number> {
const res = await fetch("/api/counter")
if (!res.ok) throw new Error("Failed to load")
const data = (await res.json()) as CounterResponse
return data.value
}
// App.tsx
import React, { useEffect } from "react"
import { useCounter } from "./store/counterStore"
export default function App() {
const count = useCounter(s => s.count)
const loading = useCounter(s => s.loading)
const increment = useCounter(s => s.increment)
const loadInitial = useCounter(s => s.loadInitial)
useEffect(() => {
void loadInitial()
}, [loadInitial])
return (
<main>
<h1>Hello App</h1>
<p>{loading ? "Loading..." : `Count: ${count}`}</p>
<button onClick={increment} disabled={loading}>
Increment
</button>
</main>
)
}
A few details are worth tightening before you copy this into a real project. As separate files, the store needs an explicit import of fetchInitialCount from the service module. increment reads the current value with get(); the functional form set((s) => ({ count: s.count + 1 })) expresses the same intent and is the more common idiom. Finally, a failed request currently just ends loading and rethrows, which leaves an unhandled rejection because the effect discards the promise with void; adding an error field to the store and catching the error there gives the UI something honest to display.
Structure that stays clear as it grows
Group code by feature rather than by file type. A folder per feature containing its store, types and UI is easier to navigate than top-level components, utils and services directories that every change has to touch. Shared utilities and the design system get their own modules. For a deeper comparison of layouts, see choosing a React folder structure.
TypeScript as the contract layer
Treat types as part of each module's public API. Export the types consumers need and keep internal ones private. Turn on strict (which includes noImplicitAny) and strict JSX settings from day one; retrofitting strictness later is far more painful. Use utility types such as Pick, Omit and ReturnType so derived types stay in sync, and type your selectors.
Using Zustand without friction
Split state into small stores by domain, for example authStore and todosStore, each created with create. Keep selectors narrow: selecting a single field avoids re-renders when unrelated fields change, whereas selecting a freshly built object on every call can cause extra renders unless you use a shallow equality helper. Zustand imposes no reducers, so updates stay short and predictable as long as you produce new values instead of mutating state.
Component and form patterns
Separate containers from presentational components. Containers talk to stores and own the logic; presentational pieces get typed props, stay pure and are trivial to test. For simple forms, controlled inputs are enough; for complex ones, a lightweight library such as react-hook-form paired with a TypeScript-aware schema keeps validation and types aligned. Use React.memo, useMemo and useCallback only where profiling shows a benefit.
Async work and side effects
Put every API call in a typed service, and let stores call services while holding only the state the UI needs. Track request status, loading, error and success, in the store so the interface always reflects what is really happening. Cancel stale long requests with AbortController, and keep a request id or freshness marker in the store so an older response cannot overwrite a newer one.
Testing and code quality
Unit tests for core business logic and store selectors are cheap and pay off quickly. Combine ESLint with TypeScript-aware rules and Prettier, and run them automatically in a pre-commit hook. Build test and Storybook data with typed fixture factories so it breaks at compile time when a model changes.
Growing without rewrites
Add capabilities vertically: a new feature means a new folder, store and route, while localization and theming live in their own modules. When an API or data model changes, let the compiler point to every broken contract. Introduce caching, normalization and optimistic updates only when real requirements call for them; if server state starts dominating the store, a dedicated data-fetching library is often a better home for it than Zustand.
Key takeaways
- Keep stores, services and components in separate, single-purpose modules, even in the smallest app.
- Read state through narrow selectors and model loading and error state explicitly.
- Enable strict TypeScript early and let types document and enforce module boundaries.
- Organize by feature, add dependencies only on demand, and optimize after measuring.
- Guard async flows with cancellation and freshness checks before race conditions reach users.