DEV Community

Cover image for Architecture Guide: Building Scalable React (or React Native) Apps with Zustand & React Query
Neetigya Chahar
Neetigya Chahar

Posted on

Architecture Guide: Building Scalable React (or React Native) Apps with Zustand & React Query

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
Enter fullscreen mode Exit fullscreen mode

Architecture Principles

Architecture Diagram
Visual representation of the data flow

  1. 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>
  );
};
Enter fullscreen mode Exit fullscreen mode
  1. 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;
};
Enter fullscreen mode Exit fullscreen mode
  1. 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 })
}));
Enter fullscreen mode Exit fullscreen mode
  1. 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());
Enter fullscreen mode Exit fullscreen mode

Key Benefits

  1. Separation of Concerns

    • UI: Only presentation
    • Business Logic: Centralized in hooks
    • State: Simple storage
    • API: Clean data fetching
  2. Reusable Logic

    • Custom hooks compose multiple stores/APIs
  3. Testability

    • Isolated store tests
    • Mocked API tests
    • Hook behavior tests
  4. Type Safety

    • End-to-end TypeScript support
    • Shared types across layers
  5. 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]);
};
Enter fullscreen mode Exit fullscreen mode

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
  });
};
Enter fullscreen mode Exit fullscreen mode

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

Output
Output of the example

Repo: zustand-with-react-query-demo

Happy hacking! ๐Ÿš€

Top comments (0)