Back to Journal
Mobile/Frontend

How to Build Frontend State Management Using React

Step-by-step tutorial for building Frontend State Management with React, from project setup through deployment.

Muneer Puthiya Purayil 18 min read

This tutorial walks through building a modern state management system in React using Zustand for global state, React Query for server state, and TypeScript for type safety. You'll implement a real-world project management application with authentication, data fetching, optimistic updates, and persistent state.

Project Setup

bash
1npx create-react-app project-manager --template typescript
2cd project-manager
3npm install zustand @tanstack/react-query react-router-dom react-hook-form zod
4npm install -D @tanstack/react-query-devtools
5 

Defining the State Architecture

Split state by concern:

typescript
1// src/types/index.ts
2export interface User {
3 id: string;
4 email: string;
5 name: string;
6 avatar: string;
7}
8 
9export interface Project {
10 id: string;
11 name: string;
12 description: string;
13 status: "active" | "archived" | "completed";
14 ownerId: string;
15 createdAt: string;
16}
17 
18export interface Task {
19 id: string;
20 projectId: string;
21 title: string;
22 status: "todo" | "in_progress" | "done";
23 assigneeId: string | null;
24 priority: "low" | "medium" | "high";
25}
26 

Global App Store with Zustand

typescript
1// src/stores/app-store.ts
2import { create } from "zustand";
3import { persist } from "zustand/middleware";
4 
5interface AppState {
6 user: User | null;
7 theme: "light" | "dark";
8 sidebarOpen: boolean;
9
10 setUser: (user: User | null) => void;
11 toggleTheme: () => void;
12 toggleSidebar: () => void;
13}
14 
15export const useAppStore = create<AppState>()(
16 persist(
17 (set) => ({
18 user: null,
19 theme: "light",
20 sidebarOpen: true,
21
22 setUser: (user) => set({ user }),
23 toggleTheme: () => set((s) => ({ theme: s.theme === "light" ? "dark" : "light" })),
24 toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
25 }),
26 { name: "app-store", partialize: (state) => ({ theme: state.theme, sidebarOpen: state.sidebarOpen }) }
27 )
28);
29 

Server State with React Query

typescript
1// src/hooks/use-projects.ts
2import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
3import { api } from "../lib/api";
4 
5export const projectKeys = {
6 all: ["projects"] as const,
7 lists: () => [...projectKeys.all, "list"] as const,
8 list: (filters: Record<string, string>) => [...projectKeys.lists(), filters] as const,
9 details: () => [...projectKeys.all, "detail"] as const,
10 detail: (id: string) => [...projectKeys.details(), id] as const,
11};
12 
13export function useProjects(filters: Record<string, string> = {}) {
14 return useQuery({
15 queryKey: projectKeys.list(filters),
16 queryFn: () => api.getProjects(filters),
17 });
18}
19 
20export function useProject(id: string) {
21 return useQuery({
22 queryKey: projectKeys.detail(id),
23 queryFn: () => api.getProject(id),
24 enabled: !!id,
25 });
26}
27 
28export function useCreateProject() {
29 const queryClient = useQueryClient();
30
31 return useMutation({
32 mutationFn: api.createProject,
33 onSuccess: () => {
34 queryClient.invalidateQueries({ queryKey: projectKeys.lists() });
35 },
36 });
37}
38 
39export function useUpdateProject() {
40 const queryClient = useQueryClient();
41
42 return useMutation({
43 mutationFn: ({ id, data }: { id: string; data: Partial<Project> }) =>
44 api.updateProject(id, data),
45 onMutate: async ({ id, data }) => {
46 await queryClient.cancelQueries({ queryKey: projectKeys.detail(id) });
47 const previous = queryClient.getQueryData<Project>(projectKeys.detail(id));
48
49 queryClient.setQueryData<Project>(projectKeys.detail(id), (old) =>
50 old ? { ...old, ...data } : undefined
51 );
52
53 return { previous };
54 },
55 onError: (_err, { id }, context) => {
56 if (context?.previous) {
57 queryClient.setQueryData(projectKeys.detail(id), context.previous);
58 }
59 },
60 onSettled: (_data, _err, { id }) => {
61 queryClient.invalidateQueries({ queryKey: projectKeys.detail(id) });
62 },
63 });
64}
65 

Task Management with Kanban State

typescript
1// src/stores/kanban-store.ts
2import { create } from "zustand";
3 
4interface KanbanState {
5 draggedTaskId: string | null;
6 selectedTaskId: string | null;
7 columnOrder: string[];
8
9 setDraggedTask: (id: string | null) => void;
10 selectTask: (id: string | null) => void;
11}
12 
13export const useKanbanStore = create<KanbanState>((set) => ({
14 draggedTaskId: null,
15 selectedTaskId: null,
16 columnOrder: ["todo", "in_progress", "done"],
17
18 setDraggedTask: (id) => set({ draggedTaskId: id }),
19 selectTask: (id) => set({ selectedTaskId: id }),
20}));
21 
typescript
1// src/hooks/use-tasks.ts
2export function useTasks(projectId: string) {
3 return useQuery({
4 queryKey: ["tasks", projectId],
5 queryFn: () => api.getTasks(projectId),
6 enabled: !!projectId,
7 });
8}
9 
10export function useMoveTask() {
11 const queryClient = useQueryClient();
12
13 return useMutation({
14 mutationFn: ({ taskId, status }: { taskId: string; status: Task["status"] }) =>
15 api.updateTask(taskId, { status }),
16 onMutate: async ({ taskId, status }) => {
17 const queryKey = ["tasks"];
18 await queryClient.cancelQueries({ queryKey });
19
20 queryClient.setQueriesData<Task[]>({ queryKey }, (old) =>
21 old?.map((t) => (t.id === taskId ? { ...t, status } : t))
22 );
23 },
24 onSettled: () => {
25 queryClient.invalidateQueries({ queryKey: ["tasks"] });
26 },
27 });
28}
29 

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

Provider Setup

typescript
1// src/App.tsx
2import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
3import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
4import { BrowserRouter, Routes, Route } from "react-router-dom";
5 
6const queryClient = new QueryClient({
7 defaultOptions: {
8 queries: {
9 staleTime: 1000 * 60, // 1 minute
10 gcTime: 1000 * 60 * 5, // 5 minutes
11 retry: 1,
12 refetchOnWindowFocus: false,
13 },
14 },
15});
16 
17function App() {
18 return (
19 <QueryClientProvider client={queryClient}>
20 <BrowserRouter>
21 <Layout>
22 <Routes>
23 <Route path="/" element={<Dashboard />} />
24 <Route path="/projects" element={<ProjectList />} />
25 <Route path="/projects/:id" element={<ProjectDetail />} />
26 </Routes>
27 </Layout>
28 </BrowserRouter>
29 <ReactQueryDevtools />
30 </QueryClientProvider>
31 );
32}
33 

Component Implementation

typescript
1// src/pages/ProjectList.tsx
2function ProjectList() {
3 const [filter, setFilter] = useState<string>("active");
4 const { data: projects, isLoading } = useProjects({ status: filter });
5 const createProject = useCreateProject();
6 
7 if (isLoading) return <Skeleton count={6} />;
8 
9 return (
10 <div>
11 <header>
12 <h1>Projects</h1>
13 <select value={filter} onChange={(e) => setFilter(e.target.value)}>
14 <option value="active">Active</option>
15 <option value="archived">Archived</option>
16 <option value="completed">Completed</option>
17 </select>
18 <button onClick={() => createProject.mutate({ name: "New Project", description: "" })}>
19 New Project
20 </button>
21 </header>
22 <div className="grid grid-cols-3 gap-4">
23 {projects?.map((project) => (
24 <ProjectCard key={project.id} project={project} />
25 ))}
26 </div>
27 </div>
28 );
29}
30 

Form State with react-hook-form

typescript
1// src/components/ProjectForm.tsx
2import { useForm } from "react-hook-form";
3import { zodResolver } from "@hookform/resolvers/zod";
4import { z } from "zod";
5 
6const projectSchema = z.object({
7 name: z.string().min(1, "Name is required").max(100),
8 description: z.string().max(500).optional(),
9 status: z.enum(["active", "archived", "completed"]),
10});
11 
12type ProjectFormData = z.infer<typeof projectSchema>;
13 
14function ProjectForm({ project, onSubmit }: {
15 project?: Project;
16 onSubmit: (data: ProjectFormData) => void;
17}) {
18 const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm<ProjectFormData>({
19 resolver: zodResolver(projectSchema),
20 defaultValues: project ?? { name: "", description: "", status: "active" },
21 });
22 
23 return (
24 <form onSubmit={handleSubmit(onSubmit)}>
25 <div>
26 <label>Name</label>
27 <input {...register("name")} />
28 {errors.name && <span className="error">{errors.name.message}</span>}
29 </div>
30 <div>
31 <label>Description</label>
32 <textarea {...register("description")} />
33 </div>
34 <div>
35 <label>Status</label>
36 <select {...register("status")}>
37 <option value="active">Active</option>
38 <option value="archived">Archived</option>
39 <option value="completed">Completed</option>
40 </select>
41 </div>
42 <button type="submit" disabled={isSubmitting}>
43 {project ? "Update" : "Create"}
44 </button>
45 </form>
46 );
47}
48 

Custom Selectors

typescript
1// src/stores/selectors.ts
2import { useAppStore } from "./app-store";
3 
4export const useIsAuthenticated = () => useAppStore((s) => s.user !== null);
5export const useCurrentUser = () => useAppStore((s) => s.user);
6export const useTheme = () => useAppStore((s) => s.theme);
7export const useSidebarOpen = () => useAppStore((s) => s.sidebarOpen);
8 

Testing

typescript
1// src/__tests__/stores.test.ts
2import { renderHook, act } from "@testing-library/react";
3import { useAppStore } from "../stores/app-store";
4 
5describe("AppStore", () => {
6 beforeEach(() => {
7 useAppStore.setState({ user: null, theme: "light", sidebarOpen: true });
8 });
9 
10 test("toggleTheme switches between light and dark", () => {
11 const { result } = renderHook(() => useAppStore());
12
13 expect(result.current.theme).toBe("light");
14 act(() => result.current.toggleTheme());
15 expect(result.current.theme).toBe("dark");
16 act(() => result.current.toggleTheme());
17 expect(result.current.theme).toBe("light");
18 });
19 
20 test("setUser updates user state", () => {
21 const { result } = renderHook(() => useAppStore());
22 const user = { id: "1", email: "[email protected]", name: "Test", avatar: "" };
23
24 act(() => result.current.setUser(user));
25 expect(result.current.user).toEqual(user);
26 });
27});
28 

Conclusion

React state management in 2025 is fundamentally about choosing the right tool for each type of state. React Query handles server state — API responses, pagination, cache invalidation — with a fraction of the code you'd write manually. Zustand handles the small amount of truly global state — auth, theme, layout preferences — with a simple API that TypeScript loves. react-hook-form handles form state. useState handles component-local state.

The combination of these tools covers every state management scenario a React application needs, with each tool handling what it does best. The result is less code, fewer bugs, better performance (selector-based subscriptions, automatic cache management), and a codebase that's easy to navigate and maintain.

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