In this blog, I'll share my approach to creating a clean architecture with Zustand stores, custom hooks, React Query, and UI components. Iโll explore how to keep business logic in custom hooks, avoid mixing it with stores, and manage API calls effectively. My goal is to structure these layers in a maintainable way while ensuring flexibility and separation of concerns. Do checkout the repo for the example.
Core Technologies
Zustand (State Management)
- ๐ Minimal Boilerplate: Simple store creation with automatic re-renders
- ๐ Immutable State: Built-in immutable updates via
set
method - ๐งฉ Middleware Support: Extend with persistence, logging, or custom logic
- ๐ Cross-Store Operations: Access multiple stores in business logic
- ๐ฆ TypeScript First: Full type safety out of the box
React Query (Server State Management)
- ๐ Smart Caching: Automatic cache management and deduping
- โก Background Updates: Silent server synchronization
- ๐ Mutations: Optimistic updates and error rollbacks
- ๐ Auto Revalidation: Stale-while-revalidate strategy
- ๐งฉ Hooks-based API: Seamless integration with React components
Folder Structure
zustand-react-query-demo/
โโโ src/
โ โโโ app/
โ โโโ examples/
โ โ โโโ product-listing/
โ โ โโโ api/
โ โ โ โโโ fetchCategories.ts
โ โ โ โโโ fetchProducts.ts
โ โ โโโ components/
โ โ โ โโโ Product.tsx
โ โ โ โโโ TopBar.tsx
โ โ โโโ hooks/
โ โ โ โโโ useCategory.ts
โ โ โ โโโ useProducts.ts
โ โ โโโ store/
โ โ โ โโโ useProductStore.ts
โ โ โโโ ProductList.tsx
โโโ tailwind.config.ts
โโโ package.json
โโโ next.config.ts
Architecture Principles
Visual representation of the data flow
-
UI Layer (Dumb Components)
- ๐ป Only handles presentation and user interactions
- ๐ Uses custom hooks for all business logic
- ๐ซ No direct store/API access
// ProductList.tsx
const ProductList: FC = () => {
const { data: products, isLoading, error } = useProducts()
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<p className="text-lg">Loading products...</p>
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<p className="text-lg text-red-500">Error loading products</p>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50">
<TopBar />
<div className="max-w-7xl mx-auto">
{/* Product grid */}
</div>
</div>
);
};
-
Business Layer (Custom Hooks)
- ๐ง Contains all business logic and side effects
- ๐ Orchestrates Zustand stores and React Query
- โป๏ธ Reusable across components/features
// useProducts.ts
const useProducts = () => {
const { selectedCategory, sortOrder } = useProductStore();
const products = useQuery<ProductType[]>({
queryKey: ['products', selectedCategory, sortOrder],
queryFn: async () => {
// If category is selected, use category-specific endpoint
if (selectedCategory) {
return fetchCategoryProducts(selectedCategory, sortOrder || undefined);
}
// Otherwise use main products endpoint
return fetchProducts(sortOrder || undefined);
},
staleTime: 1000 * 60 * 2, // Use the cache value till 2 minutes have passed
});
return products;
};
-
State Layer (Zustand Stores)
- ๐ฆ Simple state containers
- ๐ค Only basic setters/getters
- ๐ซ No business logic
// useProductStore.ts
const useProductStore = create<ProductStore>((set) => ({
selectedCategory: '',
sortOrder: '',
hasLoadedCategories: false,
setSelectedCategory: (category) => set({ selectedCategory: category }),
setSortOrder: (order) => set({ sortOrder: order }),
setHasLoadedCategories: (loaded) => set({ hasLoadedCategories: loaded })
}));
-
API Layer (React Query)
- ๐ Handles server communication
- ๐ Automatic caching/revalidation
- ๐ก Type-safe API definitions
// api/fetchProducts.ts
export const fetchProducts = (sort?: string): Promise<ProductType[]> =>
fetch(`https://fakestoreapi.com/products${sort ? `?sort=${sort}` : ''}`).then(res => res.json());
export const fetchCategoryProducts = (category: string, sort?: string): Promise<ProductType[]> =>
fetch(`https://fakestoreapi.com/products/category/${category}${sort ? `?sort=${sort}` : ''}`).then(res => res.json());
Key Benefits
-
Separation of Concerns
- UI: Only presentation
- Business Logic: Centralized in hooks
- State: Simple storage
- API: Clean data fetching
-
Reusable Logic
- Custom hooks compose multiple stores/APIs
-
Testability
- Isolated store tests
- Mocked API tests
- Hook behavior tests
-
Type Safety
- End-to-end TypeScript support
- Shared types across layers
-
Scalability
- Add features without refactoring
- Predictable patterns for team members
FAQ
Q: How to handle complex cross-store operations?
// useThemeSetup.ts
export const useThemeSetup = () => {
const {user} = useUserStore();
const {setTheme} = useUiStore();
useEffect(() => {
if(user?.preferences?.theme) {
setTheme(user.preferences.theme);
}
}, [user?.preferences]);
};
Q: How to prevent duplicate API calls?
// useUserData.ts
export const useUserData = (userId: string) => {
return useQuery({
queryKey: ['user', userId],
queryFn: () => userApi.get(userId),
staleTime: 5 * 60 * 1000
});
};
This architecture combines Zustand's lightweight state management with React Query's powerful data synchronization, creating a robust foundation for complex applications. By strictly separating concerns and centralizing business logic in custom hooks, we achieve maintainable, testable, and scalable code that grows gracefully with your application's needs.
Output
Repo: zustand-with-react-query-demo
Happy hacking! ๐
Top comments (0)