Back to Journal
Mobile/Frontend

Complete Guide to Frontend State Management with Typescript

A comprehensive guide to implementing Frontend State Management using Typescript, covering architecture, code examples, and production-ready patterns.

Muneer Puthiya Purayil 13 min read

TypeScript transforms frontend state management from a runtime debugging exercise into a compile-time safety net. Typed stores, discriminated union state machines, and generic selector patterns catch entire categories of bugs before they reach production. This guide covers implementing type-safe state management with Zustand, React Query, and custom patterns.

Type-Safe Store Design with Zustand

Zustand's TypeScript integration provides strongly typed stores with inferred action types:

typescript
1import { create } from "zustand";
2import { devtools, persist } from "zustand/middleware";
3import { immer } from "zustand/middleware/immer";
4 
5interface User {
6 id: string;
7 email: string;
8 name: string;
9 plan: "free" | "pro" | "enterprise";
10}
11 
12interface AuthState {
13 user: User | null;
14 token: string | null;
15 status: "idle" | "loading" | "authenticated" | "error";
16 error: string | null;
17 login: (email: string, password: string) => Promise<void>;
18 logout: () => void;
19 setUser: (user: User) => void;
20}
21 
22export const useAuthStore = create<AuthState>()(
23 devtools(
24 persist(
25 immer((set) => ({
26 user: null,
27 token: null,
28 status: "idle",
29 error: null,
30 
31 login: async (email, password) => {
32 set((state) => {
33 state.status = "loading";
34 state.error = null;
35 });
36 try {
37 const { user, token } = await authApi.login(email, password);
38 set((state) => {
39 state.user = user;
40 state.token = token;
41 state.status = "authenticated";
42 });
43 } catch (err) {
44 set((state) => {
45 state.status = "error";
46 state.error = err instanceof Error ? err.message : "Login failed";
47 });
48 }
49 },
50 
51 logout: () =>
52 set((state) => {
53 state.user = null;
54 state.token = null;
55 state.status = "idle";
56 }),
57 
58 setUser: (user) =>
59 set((state) => {
60 state.user = user;
61 }),
62 })),
63 { name: "auth-store" }
64 ),
65 { name: "auth" }
66 )
67);
68 

Typed Selectors

Selectors should be typed functions that narrow the store to specific values:

typescript
1// Typed selector functions
2const selectUser = (state: AuthState) => state.user;
3const selectIsAuthenticated = (state: AuthState) => state.status === "authenticated";
4const selectUserPlan = (state: AuthState) => state.user?.plan ?? "free";
5 
6// Generic selector hook for derived data
7function useSelector<T>(selector: (state: AuthState) => T): T {
8 return useAuthStore(selector);
9}
10 
11// Usage in components — only re-renders when selected value changes
12function UserBadge() {
13 const name = useAuthStore(selectUser)?.name;
14 const plan = useAuthStore(selectUserPlan);
15 return <Badge label={name} tier={plan} />;
16}
17 

State Machines with Discriminated Unions

Complex state transitions benefit from discriminated unions:

typescript
1type AsyncState<T> =
2 | { status: "idle" }
3 | { status: "loading" }
4 | { status: "success"; data: T }
5 | { status: "error"; error: Error };
6 
7interface OrderState {
8 checkout: AsyncState<Order>;
9 startCheckout: (items: CartItem[]) => Promise<void>;
10 resetCheckout: () => void;
11}
12 
13export const useOrderStore = create<OrderState>((set) => ({
14 checkout: { status: "idle" },
15 
16 startCheckout: async (items) => {
17 set({ checkout: { status: "loading" } });
18 try {
19 const order = await orderApi.create(items);
20 set({ checkout: { status: "success", data: order } });
21 } catch (err) {
22 set({ checkout: { status: "error", error: err as Error } });
23 }
24 },
25 
26 resetCheckout: () => set({ checkout: { status: "idle" } }),
27}));
28 
29// Component usage with exhaustive type narrowing
30function CheckoutStatus() {
31 const checkout = useOrderStore((s) => s.checkout);
32 
33 switch (checkout.status) {
34 case "idle":
35 return <StartCheckoutButton />;
36 case "loading":
37 return <Spinner />;
38 case "success":
39 return <OrderConfirmation order={checkout.data} />;
40 case "error":
41 return <ErrorMessage error={checkout.error} />;
42 }
43}
44 

Need a second opinion on your mobile/frontend architecture?

I run free 30-minute strategy calls for engineering teams tackling this exact problem.

Book a Free Call

React Query with TypeScript

Type-safe data fetching with React Query:

typescript
1import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
2 
3// Typed query keys prevent key collisions
4const queryKeys = {
5 users: {
6 all: ["users"] as const,
7 list: (filters: UserFilters) => ["users", "list", filters] as const,
8 detail: (id: string) => ["users", "detail", id] as const,
9 },
10 projects: {
11 all: ["projects"] as const,
12 list: (teamId: string) => ["projects", "list", teamId] as const,
13 },
14};
15 
16// Typed API client
17interface ApiClient {
18 getUsers: (filters: UserFilters) => Promise<PaginatedResponse<User>>;
19 getUser: (id: string) => Promise<User>;
20 updateUser: (id: string, data: Partial<User>) => Promise<User>;
21}
22 
23// Typed query hook
24function useUsers(filters: UserFilters) {
25 return useQuery({
26 queryKey: queryKeys.users.list(filters),
27 queryFn: () => api.getUsers(filters),
28 });
29}
30 
31// Typed mutation with optimistic update
32function useUpdateUser() {
33 const queryClient = useQueryClient();
34 
35 return useMutation({
36 mutationFn: ({ id, data }: { id: string; data: Partial<User> }) =>
37 api.updateUser(id, data),
38 
39 onMutate: async ({ id, data }) => {
40 await queryClient.cancelQueries({ queryKey: queryKeys.users.detail(id) });
41 const previous = queryClient.getQueryData<User>(queryKeys.users.detail(id));
42 
43 queryClient.setQueryData<User>(queryKeys.users.detail(id), (old) =>
44 old ? { ...old, ...data } : undefined
45 );
46 
47 return { previous };
48 },
49 
50 onError: (_err, { id }, context) => {
51 if (context?.previous) {
52 queryClient.setQueryData(queryKeys.users.detail(id), context.previous);
53 }
54 },
55 
56 onSettled: (_data, _err, { id }) => {
57 queryClient.invalidateQueries({ queryKey: queryKeys.users.detail(id) });
58 queryClient.invalidateQueries({ queryKey: queryKeys.users.all });
59 },
60 });
61}
62 

Generic Store Patterns

Create reusable store patterns for common state shapes:

typescript
1interface EntityState<T extends { id: string }> {
2 entities: Record<string, T>;
3 ids: string[];
4 selectedId: string | null;
5 getById: (id: string) => T | undefined;
6 getAll: () => T[];
7 add: (entity: T) => void;
8 update: (id: string, changes: Partial<T>) => void;
9 remove: (id: string) => void;
10 select: (id: string | null) => void;
11}
12 
13function createEntityStore<T extends { id: string }>() {
14 return create<EntityState<T>>((set, get) => ({
15 entities: {},
16 ids: [],
17 selectedId: null,
18 
19 getById: (id) => get().entities[id],
20 getAll: () => get().ids.map((id) => get().entities[id]),
21 
22 add: (entity) =>
23 set((state) => ({
24 entities: { ...state.entities, [entity.id]: entity },
25 ids: [...state.ids, entity.id],
26 })),
27 
28 update: (id, changes) =>
29 set((state) => ({
30 entities: {
31 ...state.entities,
32 [id]: { ...state.entities[id], ...changes },
33 },
34 })),
35 
36 remove: (id) =>
37 set((state) => ({
38 entities: Object.fromEntries(
39 Object.entries(state.entities).filter(([key]) => key !== id)
40 ),
41 ids: state.ids.filter((i) => i !== id),
42 selectedId: state.selectedId === id ? null : state.selectedId,
43 })),
44 
45 select: (id) => set({ selectedId: id }),
46 }));
47}
48 
49// Usage
50const useProjectStore = createEntityStore<Project>();
51const useTodoStore = createEntityStore<Todo>();
52 

Testing Typed Stores

typescript
1import { act, renderHook } from "@testing-library/react";
2import { useAuthStore } from "./auth-store";
3 
4describe("AuthStore", () => {
5 beforeEach(() => {
6 useAuthStore.setState({
7 user: null, token: null, status: "idle", error: null,
8 });
9 });
10 
11 test("login transitions through correct states", async () => {
12 const { result } = renderHook(() => useAuthStore());
13 
14 expect(result.current.status).toBe("idle");
15 
16 await act(async () => {
17 await result.current.login("[email protected]", "password");
18 });
19 
20 expect(result.current.status).toBe("authenticated");
21 expect(result.current.user).toBeDefined();
22 expect(result.current.user?.email).toBe("[email protected]");
23 });
24 
25 test("logout resets state", () => {
26 const { result } = renderHook(() => useAuthStore());
27
28 act(() => {
29 useAuthStore.setState({
30 user: { id: "1", email: "[email protected]", name: "Test", plan: "pro" },
31 status: "authenticated",
32 });
33 });
34 
35 act(() => result.current.logout());
36 
37 expect(result.current.user).toBeNull();
38 expect(result.current.status).toBe("idle");
39 });
40});
41 

Conclusion

TypeScript's type system makes frontend state management significantly safer and more maintainable. Discriminated unions for async states eliminate impossible state combinations. Typed selectors prevent subscribing to wrong state shapes. Generic store patterns provide reusable, type-safe foundations for entity management. The compile-time safety net catches bugs that would otherwise surface as subtle runtime failures in production.

The key insight is that TypeScript's value for state management comes from the type definitions, not the implementation. Define your state types precisely — use unions instead of optional fields, literals instead of strings, and branded types for IDs — and the TypeScript compiler becomes a powerful static analysis tool for your state management layer.

FAQ

Need expert help?

Building with mobile/frontend?

I help teams ship production-grade systems. From architecture review to hands-on builds.

Muneer Puthiya Purayil

SaaS Architect & AI Systems Engineer. 10+ years shipping production infrastructure across fintech, automotive, e-commerce, and healthcare.

Engage

Start a
Conversation.

For teams building at scale: SaaS platforms, agentic AI systems, and enterprise mobile infrastructure. Scope and fit are evaluated before any engagement begins.

Limited availability · Q3 / Q4 2026