Quản Lý State Trong React Native 2026: Zustand, TanStack Query Và MMKV

Hướng dẫn chi tiết quản lý state React Native 2026 với bộ ba Zustand, TanStack Query và MMKV. Bao gồm kiến trúc hybrid, code mẫu thực tế, optimistic updates và best practices cho ứng dụng production.

Giới thiệu: Tại sao quản lý state vẫn là bài toán cốt lõi trong React Native 2026

Nếu bạn đã từng phát triển một ứng dụng React Native vượt qua mức "Hello World", bạn chắc chắn đã đối mặt với câu hỏi kinh điển: dữ liệu nên sống ở đâu, được chia sẻ như thế nào, và khi nào thì cập nhật? Đó chính là bài toán quản lý state — và nói thật, trong năm 2026, cách chúng ta giải quyết bài toán này đã thay đổi hoàn toàn so với vài năm trước.

Hãy nhìn lại một chút. Từ 2019 đến khoảng 2022, Redux thống trị gần như tuyệt đối. Mọi dự án React Native đều mặc định cài Redux, tạo store tập trung, viết action, reducer, middleware — một kiến trúc mạnh mẽ nhưng đi kèm hàng trăm dòng boilerplate cho mỗi tính năng nhỏ. Redux Toolkit cải thiện trải nghiệm đáng kể, nhưng triết lý "một store chứa tất cả" bắt đầu bộc lộ hạn chế khi ứng dụng ngày càng phức tạp.

Bước sang 2026, bức tranh đã khác hẳn. Với Expo SDK 55 và React Native 0.83 sử dụng New Architecture làm chuẩn duy nhất, cùng React 19.2 mang đến concurrent rendering và các API mới, cộng đồng đã dịch chuyển mạnh mẽ sang cách tiếp cận mà mình gọi là hybrid state management — kết hợp nhiều công cụ chuyên biệt thay vì ép mọi thứ vào một giải pháp duy nhất.

Bộ ba "quyền lực" trong năm 2026? Zustand cho client state, TanStack Query cho server state, và MMKV cho persistent state. Mỗi thằng giải quyết một loại vấn đề khác nhau, và khi kết hợp lại, chúng tạo nên một kiến trúc vừa đơn giản vừa mạnh mẽ.

Bài viết này sẽ đi sâu vào từng công cụ với ví dụ mã thực tế, sau đó ghép chúng lại trong một kiến trúc ứng dụng hoàn chỉnh. Nếu bạn đã đọc bài viết về Expo SDK 55tối ưu hiệu năng React Native trước đó, bài này sẽ bổ sung mảnh ghép còn thiếu — cách tổ chức dữ liệu trong ứng dụng của bạn một cách hiệu quả nhất.

Hiểu rõ các loại State: Chìa khóa của kiến trúc hiện đại

Insight quan trọng nhất trong quản lý state năm 2026 không phải là "dùng thư viện nào", mà là nhận biết bạn đang xử lý loại state nào. Mỗi loại state có đặc điểm riêng và cần công cụ phù hợp.

Ép tất cả vào một store duy nhất giống như dùng búa để vặn ốc — hoạt động được, nhưng không hiệu quả chút nào.

Client State — Trạng thái phía client

Đây là dữ liệu chỉ tồn tại trong ứng dụng, không đến từ server. Ví dụ điển hình:

  • UI State: modal đang mở hay đóng, tab nào đang active, sidebar có hiển thị không
  • Form State: giá trị các input, trạng thái validation
  • Theme: dark mode hay light mode
  • Auth tokens: JWT token sau khi đăng nhập
  • Cart state: các sản phẩm trong giỏ hàng (trước khi submit lên server)

Client state thường thay đổi đồng bộ (synchronous) và cần phản hồi ngay lập tức khi user tương tác. Công cụ lý tưởng cho loại này: Zustand.

Server State — Dữ liệu từ server

Đây là dữ liệu bạn fetch từ API — danh sách sản phẩm, thông tin user profile, feed bài viết, v.v. Server state có những đặc điểm rất khác biệt so với client state:

  • Dữ liệu thực sự nằm trên server, bạn chỉ giữ bản copy (cache) ở client
  • Dữ liệu có thể bị stale (lỗi thời) bất cứ lúc nào
  • Nhiều component có thể cần cùng một dữ liệu (deduplication)
  • Cần xử lý loading, error, refetch, pagination, optimistic updates

Nếu bạn đặt server state vào Zustand hay Redux, bạn phải tự tay xây dựng caching, invalidation, background refetching — tức là bạn đang tái phát minh cái bánh xe. Công cụ lý tưởng ở đây: TanStack Query (trước đây gọi là React Query).

Persistent State — Dữ liệu cần lưu trữ lâu dài

Đây là dữ liệu cần tồn tại ngay cả khi user tắt app và mở lại:

  • Cài đặt người dùng (theme, ngôn ngữ, kích thước font)
  • Auth token (để user không phải đăng nhập lại mỗi lần mở app)
  • Dữ liệu offline (draft bài viết, giỏ hàng chưa thanh toán)
  • Onboarding flags (đã xem hướng dẫn chưa)

Persistent state cần được lưu xuống bộ nhớ thiết bị. AsyncStorage từng là lựa chọn mặc định, nhưng trong 2026, MMKV đã thay thế hoàn toàn nhờ tốc độ nhanh hơn đến 30 lần và hỗ trợ đọc ghi đồng bộ.

Khi bạn hiểu rõ ba loại state này, mọi quyết định kiến trúc trở nên rõ ràng hơn nhiều: Zustand cho client state, TanStack Query cho server state, MMKV làm lớp persistence. Giờ hãy đi vào chi tiết từng công cụ nhé.

Zustand cho Client State: Đơn giản mà mạnh mẽ

Zustand (tiếng Đức nghĩa là "trạng thái") là thư viện quản lý state phổ biến nhất trong hệ sinh thái React Native năm 2026. Được tạo bởi team pmndrs — cùng nhóm phát triển Jotai, React Three Fiber và Valtio — Zustand chinh phục cộng đồng bằng triết lý "làm ít hơn, làm đúng hơn".

Tại sao Zustand thắng trong 2026?

Thật ra có khá nhiều lý do, nhưng đây là những điểm nổi bật nhất:

  • Không cần Provider: Khác hoàn toàn với Redux hay Context API, Zustand không yêu cầu bọc Provider quanh component tree. Store hoạt động ở ngoài React, component chỉ subscribe vào những phần cần thiết.
  • Dùng useSyncExternalStore: Dưới hood, Zustand v5 sử dụng useSyncExternalStore — API chính thức của React 18+ cho external state. Điều này đảm bảo tương thích hoàn hảo với concurrent rendering của React 19.
  • Bundle size siêu nhỏ: Chỉ khoảng 3KB gzipped, so với 40KB+ của Redux Toolkit kèm dependencies. Chênh lệch hơn 10 lần!
  • TypeScript first-class: Type inference hoạt động rất tự nhiên, không cần khai báo type phức tạp kiểu Redux.
  • Boilerplate tối thiểu: Một store Zustand thường chỉ cần 20-30 dòng code thay vì hàng trăm dòng với Redux.

Thiết lập store cơ bản với TypeScript

Cài đặt Zustand đơn giản chỉ cần một lệnh:

npx expo install zustand

Dưới đây là ví dụ tạo một auth store hoàn chỉnh — đây cũng là pattern mình dùng trong hầu hết các dự án:

// stores/useAuthStore.ts
import { create } from 'zustand';

interface User {
  id: string;
  name: string;
  email: string;
  avatarUrl: string | null;
}

interface AuthState {
  user: User | null;
  token: string | null;
  isAuthenticated: boolean;

  // Actions
  setAuth: (user: User, token: string) => void;
  logout: () => void;
  updateUser: (data: Partial<User>) => void;
}

export const useAuthStore = create<AuthState>()((set, get) => ({
  user: null,
  token: null,
  isAuthenticated: false,

  setAuth: (user, token) =>
    set({ user, token, isAuthenticated: true }),

  logout: () =>
    set({ user: null, token: null, isAuthenticated: false }),

  updateUser: (data) => {
    const currentUser = get().user;
    if (currentUser) {
      set({ user: { ...currentUser, ...data } });
    }
  },
}));

Lưu ý cú pháp create<AuthState>()(... với hai cặp ngoặc — đây là cách Zustand v5 yêu cầu để TypeScript suy luận type chính xác. Store chứa cả data lẫn actions trong cùng một object, không phải tách riêng như Redux. Mình thấy cách tổ chức này trực quan hơn rất nhiều.

Selectors: Tránh re-render không cần thiết

Đây là một trong những kỹ thuật quan trọng nhất khi dùng Zustand, và cũng là chỗ nhiều người hay mắc sai lầm. Thay vì lấy toàn bộ store, bạn chỉ subscribe vào đúng phần dữ liệu cần thiết:

// Component chỉ re-render khi user thay đổi
const user = useAuthStore((state) => state.user);

// Component chỉ re-render khi isAuthenticated thay đổi
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);

// Lấy action — không gây re-render vì function reference không đổi
const logout = useAuthStore((state) => state.logout);

Khi cần lấy nhiều giá trị cùng lúc, hãy sử dụng useShallow để tránh re-render do tạo object mới mỗi lần render:

import { useShallow } from 'zustand/shallow';

// SAI - tạo object mới mỗi lần render => re-render liên tục
const { user, token } = useAuthStore((state) => ({
  user: state.user,
  token: state.token,
}));

// ĐÚNG - dùng useShallow để so sánh shallow
const { user, token } = useAuthStore(
  useShallow((state) => ({
    user: state.user,
    token: state.token,
  }))
);

Middleware: immer và devtools

Zustand hỗ trợ hệ thống middleware khá linh hoạt. Hai middleware phổ biến nhất là immer (cho phép mutate state trực tiếp) và devtools (kết nối với Redux DevTools — vâng, đúng vậy, Redux DevTools):

// stores/useCartStore.ts
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
import { devtools } from 'zustand/middleware';

interface CartItem {
  id: string;
  name: string;
  price: number;
  quantity: number;
}

interface CartState {
  items: CartItem[];
  addItem: (item: Omit<CartItem, 'quantity'>) => void;
  removeItem: (id: string) => void;
  updateQuantity: (id: string, quantity: number) => void;
  clearCart: () => void;
  totalPrice: () => number;
}

export const useCartStore = create<CartState>()(
  devtools(
    immer((set, get) => ({
      items: [],

      addItem: (item) =>
        set((state) => {
          const existing = state.items.find((i) => i.id === item.id);
          if (existing) {
            existing.quantity += 1; // Mutate trực tiếp nhờ immer!
          } else {
            state.items.push({ ...item, quantity: 1 });
          }
        }),

      removeItem: (id) =>
        set((state) => {
          state.items = state.items.filter((i) => i.id !== id);
        }),

      updateQuantity: (id, quantity) =>
        set((state) => {
          const item = state.items.find((i) => i.id === id);
          if (item) {
            item.quantity = Math.max(0, quantity);
          }
        }),

      clearCart: () => set({ items: [] }),

      totalPrice: () =>
        get().items.reduce(
          (sum, item) => sum + item.price * item.quantity,
          0
        ),
    })),
    { name: 'CartStore' }
  )
);

Nhờ immer, bạn có thể viết code kiểu mutate trực tiếp (push, gán giá trị) mà Zustand vẫn tạo ra state immutable phía dưới. Nói thật, khi làm việc với nested objects và arrays phức tạp, immer là cứu tinh.

Multiple stores pattern

Khác với Redux khuyến khích một store duy nhất, Zustand lại khuyến khích tạo nhiều store nhỏ, mỗi store quản lý một domain riêng biệt: useAuthStore, useCartStore, useUIStore, useSettingsStore. Cách tiếp cận này mang lại nhiều lợi ích thiết thực:

  • Mỗi store độc lập, dễ test riêng lẻ
  • Component chỉ subscribe vào store liên quan, giảm re-render đáng kể
  • Code organization rõ ràng theo domain
  • Dễ dàng thêm/xóa feature mà không ảnh hưởng store khác

So sánh Zustand với các giải pháp khác

Tiêu chí Zustand Redux Toolkit Jotai Context + useReducer
Bundle size ~3KB ~40KB ~3.5KB 0KB (built-in)
Cần Provider Không Có (optional)
Boilerplate Rất ít Trung bình Rất ít Nhiều
TypeScript DX Tuyệt vời Tốt Tuyệt vời Trung bình
Mô hình Single store Single store Atomic Single store
Middleware persist, immer, devtools Rất đa dạng Hạn chế Tự xây dựng
Phù hợp cho Hầu hết dự án Dự án enterprise lớn UI phức tạp, fine-grained Ứng dụng nhỏ

TanStack Query cho Server State: Dữ liệu từ API chưa bao giờ dễ dàng hơn

Nếu Zustand quản lý những gì thuộc về client, thì TanStack Query (trước đây là React Query) quản lý mọi thứ đến từ server. Và tin mình đi — một khi bạn dùng TanStack Query, bạn sẽ không bao giờ muốn quay lại cách cũ. Tự viết useEffect + useState để fetch data sẽ cảm thấy như thời kỳ đồ đá.

Tại sao cần tách biệt server state?

Server state có những yêu cầu mà client state library đơn giản là không thể đáp ứng hiệu quả:

  • Caching thông minh: Khi user quay lại màn hình đã load, data hiển thị ngay từ cache rồi refetch ngầm phía sau
  • Deduplication: 5 component cùng gọi useQuery('users') chỉ tạo 1 request duy nhất — tiết kiệm bandwidth đáng kể
  • Background refetching: Data tự cập nhật khi app focus lại hoặc kết nối mạng được khôi phục
  • Optimistic updates: UI cập nhật ngay lập tức, rollback nếu server từ chối
  • Pagination & Infinite scroll: Hỗ trợ sẵn với useInfiniteQuery
  • Retry logic: Tự động retry khi request thất bại

Tất cả những thứ trên, nếu tự xây dựng từ đầu, sẽ tốn hàng ngàn dòng code và thành thật mà nói, vẫn không tốt bằng TanStack Query đâu.

Thiết lập QueryClient trong React Native

Cài đặt cũng rất nhanh:

npx expo install @tanstack/react-query

Thiết lập QueryClient với cấu hình phù hợp cho mobile:

// providers/QueryProvider.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactNode } from 'react';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      // Data được coi là "fresh" trong 30 giây
      staleTime: 30 * 1000,
      // Cache giữ data trong 5 phút sau khi không còn subscriber
      gcTime: 5 * 60 * 1000,
      // Retry 2 lần khi thất bại
      retry: 2,
      // Refetch khi app quay lại foreground
      refetchOnWindowFocus: true,
      // Refetch khi kết nối mạng được khôi phục
      refetchOnReconnect: true,
    },
  },
});

interface Props {
  children: ReactNode;
}

export function QueryProvider({ children }: Props) {
  return (
    <QueryClientProvider client={queryClient}>
      {children}
    </QueryClientProvider>
  );
}

Wrap ứng dụng trong QueryProvider ở layout gốc (nếu bạn dùng Expo Router):

// app/_layout.tsx
import { Stack } from 'expo-router';
import { QueryProvider } from '../providers/QueryProvider';

export default function RootLayout() {
  return (
    <QueryProvider>
      <Stack />
    </QueryProvider>
  );
}

useQuery: Fetch dữ liệu với loading/error states

Dưới đây là ví dụ thực tế — fetch danh sách sản phẩm cho một ứng dụng e-commerce:

// api/products.ts
import { useQuery } from '@tanstack/react-query';

interface Product {
  id: string;
  name: string;
  price: number;
  imageUrl: string;
  category: string;
}

async function fetchProducts(category?: string): Promise<Product[]> {
  const url = category
    ? `https://api.myshop.com/products?category=${category}`
    : 'https://api.myshop.com/products';

  const response = await fetch(url);
  if (!response.ok) {
    throw new Error('Failed to fetch products');
  }
  return response.json();
}

export function useProducts(category?: string) {
  return useQuery({
    queryKey: ['products', { category }],
    queryFn: () => fetchProducts(category),
    staleTime: 60 * 1000, // Fresh trong 1 phút
  });
}

// Sử dụng trong component
// screens/ProductListScreen.tsx
import { View, Text, FlatList, ActivityIndicator } from 'react-native';
import { useProducts } from '../api/products';

export function ProductListScreen() {
  const { data: products, isLoading, error, refetch } = useProducts();

  if (isLoading) {
    return (
      <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
        <ActivityIndicator size="large" color="#6366f1" />
        <Text style={{ marginTop: 12 }}>Đang tải sản phẩm...</Text>
      </View>
    );
  }

  if (error) {
    return (
      <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
        <Text style={{ color: '#ef4444' }}>Lỗi: {error.message}</Text>
        <Text
          onPress={() => refetch()}
          style={{ color: '#6366f1', marginTop: 8 }}
        >
          Thử lại
        </Text>
      </View>
    );
  }

  return (
    <FlatList
      data={products}
      keyExtractor={(item) => item.id}
      renderItem={({ item }) => (
        <View style={{ padding: 16, borderBottomWidth: 1, borderColor: '#e5e7eb' }}>
          <Text style={{ fontSize: 16, fontWeight: '600' }}>{item.name}</Text>
          <Text style={{ color: '#6b7280', marginTop: 4 }}>
            {item.price.toLocaleString('vi-VN')}đ
          </Text>
        </View>
      )}
      onRefresh={refetch}
      refreshing={isLoading}
    />
  );
}

Chú ý queryKey: ['products', { category }] — TanStack Query sẽ tự động tạo cache riêng cho mỗi category khác nhau. Khi category thay đổi, nó fetch data mới nhưng vẫn giữ cache cũ để hiển thị ngay khi user quay lại. Rất thông minh phải không?

useMutation: Tạo/cập nhật dữ liệu với optimistic update

Mutation dùng cho các thao tác thay đổi dữ liệu trên server (POST, PUT, DELETE). Đây là phần mình thấy TanStack Query thực sự tỏa sáng — ví dụ tạo sản phẩm mới với optimistic update:

// api/useCreateProduct.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';

interface CreateProductInput {
  name: string;
  price: number;
  category: string;
}

async function createProduct(input: CreateProductInput): Promise<Product> {
  const response = await fetch('https://api.myshop.com/products', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(input),
  });
  if (!response.ok) throw new Error('Failed to create product');
  return response.json();
}

export function useCreateProduct() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: createProduct,

    // Optimistic update: cập nhật UI ngay trước khi server phản hồi
    onMutate: async (newProduct) => {
      // Cancel các query đang chạy để tránh ghi đè
      await queryClient.cancelQueries({ queryKey: ['products'] });

      // Lưu state hiện tại để rollback nếu cần
      const previousProducts = queryClient.getQueryData<Product[]>(['products']);

      // Thêm sản phẩm mới vào cache ngay lập tức
      queryClient.setQueryData<Product[]>(['products'], (old) => [
        ...(old || []),
        { ...newProduct, id: 'temp-' + Date.now(), imageUrl: '' },
      ]);

      return { previousProducts };
    },

    // Nếu mutation thất bại, rollback về state cũ
    onError: (_error, _newProduct, context) => {
      if (context?.previousProducts) {
        queryClient.setQueryData(['products'], context.previousProducts);
      }
    },

    // Sau khi thành công hoặc thất bại, invalidate cache để refetch data mới
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['products'] });
    },
  });
}

Với optimistic update, UI phản hồi ngay lập tức — user thêm sản phẩm và thấy nó xuất hiện trong danh sách mà không phải chờ server. Nếu server từ chối, UI tự rollback về trạng thái cũ. Trải nghiệm mượt mà y như ứng dụng native.

Tích hợp NetInfo: Online/Offline detection

Trên mobile, kết nối mạng không phải lúc nào cũng ổn định (ai từng dùng app trong hầm để xe hiểu ngay). TanStack Query tích hợp sẵn onlineManager để xử lý chuyện này:

// lib/onlineManager.ts
import NetInfo from '@react-native-community/netinfo';
import { onlineManager } from '@tanstack/react-query';

// Đồng bộ trạng thái online/offline với TanStack Query
onlineManager.setEventListener((setOnline) => {
  return NetInfo.addEventListener((state) => {
    setOnline(!!state.isConnected);
  });
});

Import file này ở root layout, và TanStack Query sẽ tự động:

  • Tạm dừng các query khi offline
  • Tự động refetch tất cả stale queries khi online trở lại
  • Giữ các mutation trong queue và thực hiện khi có kết nối

Cấu hình staleTime và gcTime

Hiểu đúng hai giá trị này khá quan trọng — và cũng là chỗ nhiều người hay bị nhầm lẫn:

  • staleTime (mặc định: 0): Bao lâu thì data được coi là "fresh". Trong thời gian này, TanStack Query trả về cache mà không fetch lại. Đặt 30-60 giây cho data thay đổi thường xuyên, 5-10 phút cho data ít thay đổi.
  • gcTime (mặc định: 5 phút): Bao lâu thì cache bị xóa sau khi không còn component nào subscribe. Nên tăng giá trị này nếu user hay quay đi quay lại giữa các màn hình.
// Ví dụ: User profile ít thay đổi, cache lâu hơn
export function useUserProfile(userId: string) {
  return useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUserProfile(userId),
    staleTime: 5 * 60 * 1000,  // Fresh trong 5 phút
    gcTime: 30 * 60 * 1000,    // Giữ cache 30 phút
  });
}

// Ví dụ: Feed tin tức thay đổi liên tục
export function useNewsFeed() {
  return useQuery({
    queryKey: ['newsfeed'],
    queryFn: fetchNewsFeed,
    staleTime: 10 * 1000,       // Fresh trong 10 giây
    gcTime: 5 * 60 * 1000,      // Giữ cache 5 phút
    refetchInterval: 30 * 1000,  // Tự refetch mỗi 30 giây
  });
}

MMKV cho Persistent State: Nhanh hơn AsyncStorage 30 lần

Khi bạn cần lưu dữ liệu xuống bộ nhớ thiết bị — auth tokens, cài đặt user, offline data — thì MMKV là lựa chọn tốt nhất trong React Native năm 2026. MMKV ban đầu được phát triển bởi WeChat (Tencent) cho hơn 1 tỷ user, và thư viện react-native-mmkv mang sức mạnh đó đến React Native.

Mình vẫn nhớ lần đầu chuyển từ AsyncStorage sang MMKV — sự khác biệt về tốc độ cảm nhận được ngay lập tức, đặc biệt khi app khởi động.

Tại sao MMKV thay vì AsyncStorage?

  • Nhanh hơn ~30 lần: MMKV sử dụng memory-mapped files, trong khi AsyncStorage dùng SQLite với async I/O
  • Đọc/ghi đồng bộ (synchronous): Không cần await, không cần xử lý Promise — giá trị có ngay lập tức
  • Hỗ trợ mã hóa: Encrypt dữ liệu nhạy cảm (auth tokens) chỉ với một tham số
  • Tương thích New Architecture: Hoạt động hoàn hảo với Fabric và TurboModules trên Expo SDK 55

Cài đặt và sử dụng cơ bản

npx expo install react-native-mmkv

Sử dụng trực tiếp — đơn giản đến bất ngờ:

// lib/storage.ts
import { MMKV } from 'react-native-mmkv';

// Storage mặc định
export const storage = new MMKV();

// Storage riêng cho dữ liệu nhạy cảm, có mã hóa
export const secureStorage = new MMKV({
  id: 'secure-storage',
  encryptionKey: 'your-encryption-key',
});

// Sử dụng cơ bản
storage.set('username', 'NguyenVanA');
storage.set('onboarded', true);
storage.set('lastLoginTimestamp', Date.now());

const username = storage.getString('username');    // 'NguyenVanA'
const onboarded = storage.getBoolean('onboarded'); // true
const timestamp = storage.getNumber('lastLoginTimestamp');

// Xóa
storage.delete('username');

// Lưu object (cần serialize)
const userPrefs = { theme: 'dark', fontSize: 16, language: 'vi' };
storage.set('preferences', JSON.stringify(userPrefs));
const prefs = JSON.parse(storage.getString('preferences') || '{}')

Zustand Persist Middleware với MMKV Storage Adapter

Sức mạnh thực sự của MMKV nằm ở khả năng kết hợp với Zustand persist middleware. Bạn tạo một storage adapter đơn giản để Zustand tự động lưu/khôi phục state từ MMKV:

// lib/mmkvStorage.ts
import { MMKV } from 'react-native-mmkv';
import { StateStorage } from 'zustand/middleware';

const mmkv = new MMKV();

export const mmkvStateStorage: StateStorage = {
  setItem: (name, value) => {
    mmkv.set(name, value);
  },
  getItem: (name) => {
    return mmkv.getString(name) ?? null;
  },
  removeItem: (name) => {
    mmkv.delete(name);
  },
};

Ví dụ hoàn chỉnh: Persist auth state và user preferences

Đây là phần mà mọi thứ kết nối lại với nhau — và theo mình, đây cũng là lúc bạn thực sự thấy sức mạnh của combo Zustand + MMKV:

// stores/usePersistedAuthStore.ts
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { mmkvStateStorage } from '../lib/mmkvStorage';

interface PersistedAuthState {
  token: string | null;
  refreshToken: string | null;
  userId: string | null;
  setTokens: (token: string, refreshToken: string, userId: string) => void;
  clearTokens: () => void;
}

export const usePersistedAuthStore = create<PersistedAuthState>()(
  persist(
    (set) => ({
      token: null,
      refreshToken: null,
      userId: null,

      setTokens: (token, refreshToken, userId) =>
        set({ token, refreshToken, userId }),

      clearTokens: () =>
        set({ token: null, refreshToken: null, userId: null }),
    }),
    {
      name: 'auth-storage',
      storage: createJSONStorage(() => mmkvStateStorage),
    }
  )
);

// stores/usePreferencesStore.ts
interface PreferencesState {
  theme: 'light' | 'dark' | 'system';
  language: 'vi' | 'en';
  fontSize: 'small' | 'medium' | 'large';
  notificationsEnabled: boolean;
  setTheme: (theme: 'light' | 'dark' | 'system') => void;
  setLanguage: (lang: 'vi' | 'en') => void;
  setFontSize: (size: 'small' | 'medium' | 'large') => void;
  toggleNotifications: () => void;
}

export const usePreferencesStore = create<PreferencesState>()(
  persist(
    (set) => ({
      theme: 'system',
      language: 'vi',
      fontSize: 'medium',
      notificationsEnabled: true,

      setTheme: (theme) => set({ theme }),
      setLanguage: (language) => set({ language }),
      setFontSize: (fontSize) => set({ fontSize }),
      toggleNotifications: () =>
        set((state) => ({
          notificationsEnabled: !state.notificationsEnabled,
        })),
    }),
    {
      name: 'preferences-storage',
      storage: createJSONStorage(() => mmkvStateStorage),
      // Chỉ persist các giá trị cần thiết, loại bỏ functions
      partialize: (state) => ({
        theme: state.theme,
        language: state.language,
        fontSize: state.fontSize,
        notificationsEnabled: state.notificationsEnabled,
      }),
    }
  )
);

Với setup này, khi user thay đổi theme hoặc ngôn ngữ, giá trị được lưu vào MMKV ngay lập tức (synchronous). Khi mở lại app, Zustand tự động khôi phục state từ MMKV — user không phải cài đặt lại bất cứ thứ gì. Smooth.

Kết hợp cả ba: Kiến trúc ứng dụng thực tế

OK, giờ là lúc ghép tất cả lại với nhau. Mình sẽ demo kiến trúc cho một ứng dụng e-commerce với đầy đủ các lớp state — đây cũng là kiến trúc mình đang áp dụng cho dự án thực tế.

Tổng quan kiến trúc

Hãy hình dung kiến trúc theo ba tầng:

  • Tầng 1 — Server State (TanStack Query): Danh sách sản phẩm, chi tiết sản phẩm, lịch sử đơn hàng, thông tin user profile từ API
  • Tầng 2 — Client State (Zustand): Giỏ hàng, trạng thái UI (modal, filter, sort), trạng thái auth (user session)
  • Tầng 3 — Persistent State (Zustand + MMKV): Auth tokens (tồn tại qua restart), cài đặt người dùng (theme, ngôn ngữ), onboarding flags

Luồng dữ liệu rất rõ ràng: API data đi qua TanStack Query, client data sống trong Zustand stores, và những gì cần persist thì Zustand tự động đồng bộ xuống MMKV qua persist middleware.

Code hoàn chỉnh: Store setup

// stores/useCartStore.ts
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
import { mmkvStateStorage } from '../lib/mmkvStorage';

interface CartItem {
  productId: string;
  name: string;
  price: number;
  quantity: number;
  imageUrl: string;
}

interface CartState {
  items: CartItem[];
  addToCart: (product: Omit<CartItem, 'quantity'>) => void;
  removeFromCart: (productId: string) => void;
  updateQuantity: (productId: string, quantity: number) => void;
  clearCart: () => void;
  getTotal: () => number;
  getItemCount: () => number;
}

export const useCartStore = create<CartState>()(
  persist(
    immer((set, get) => ({
      items: [],

      addToCart: (product) =>
        set((state) => {
          const existing = state.items.find(
            (item) => item.productId === product.productId
          );
          if (existing) {
            existing.quantity += 1;
          } else {
            state.items.push({ ...product, quantity: 1 });
          }
        }),

      removeFromCart: (productId) =>
        set((state) => {
          state.items = state.items.filter(
            (item) => item.productId !== productId
          );
        }),

      updateQuantity: (productId, quantity) =>
        set((state) => {
          const item = state.items.find((i) => i.productId === productId);
          if (item) {
            if (quantity <= 0) {
              state.items = state.items.filter(
                (i) => i.productId !== productId
              );
            } else {
              item.quantity = quantity;
            }
          }
        }),

      clearCart: () => set({ items: [] }),

      getTotal: () =>
        get().items.reduce(
          (sum, item) => sum + item.price * item.quantity,
          0
        ),

      getItemCount: () =>
        get().items.reduce((sum, item) => sum + item.quantity, 0),
    })),
    {
      name: 'cart-storage',
      storage: createJSONStorage(() => mmkvStateStorage),
      partialize: (state) => ({ items: state.items }),
    }
  )
);

API Hooks với TanStack Query

// api/useProducts.ts
import { useQuery, useInfiniteQuery } from '@tanstack/react-query';
import { usePersistedAuthStore } from '../stores/usePersistedAuthStore';

const API_BASE = 'https://api.myshop.com';

interface Product {
  id: string;
  name: string;
  price: number;
  imageUrl: string;
  category: string;
  description: string;
  rating: number;
  inStock: boolean;
}

interface ProductsResponse {
  products: Product[];
  nextCursor: string | null;
  total: number;
}

// Helper: tạo authenticated fetch
function useAuthenticatedFetch() {
  const token = usePersistedAuthStore((state) => state.token);

  return async (url: string, options?: RequestInit) => {
    const response = await fetch(url, {
      ...options,
      headers: {
        ...options?.headers,
        'Content-Type': 'application/json',
        ...(token ? { Authorization: `Bearer ${token}` } : {}),
      },
    });
    if (!response.ok) {
      throw new Error(`API Error: ${response.status}`);
    }
    return response.json();
  };
}

// Hook: Danh sách sản phẩm với infinite scroll
export function useProductList(category?: string) {
  const authFetch = useAuthenticatedFetch();

  return useInfiniteQuery({
    queryKey: ['products', 'list', { category }],
    queryFn: async ({ pageParam }) => {
      const params = new URLSearchParams();
      if (category) params.set('category', category);
      if (pageParam) params.set('cursor', pageParam);
      params.set('limit', '20');

      return authFetch(
        `${API_BASE}/products?${params.toString()}`
      ) as Promise<ProductsResponse>;
    },
    initialPageParam: null as string | null,
    getNextPageParam: (lastPage) => lastPage.nextCursor,
    staleTime: 60 * 1000,
  });
}

// Hook: Chi tiết sản phẩm
export function useProductDetail(productId: string) {
  const authFetch = useAuthenticatedFetch();

  return useQuery({
    queryKey: ['products', 'detail', productId],
    queryFn: () =>
      authFetch(`${API_BASE}/products/${productId}`) as Promise<Product>,
    staleTime: 5 * 60 * 1000,
    enabled: !!productId,
  });
}

Screen Component: Ghép tất cả lại

Đây là phần thú vị nhất — khi cả ba tầng state cùng hoạt động trong một screen component:

// screens/ShopScreen.tsx
import { View, Text, FlatList, TouchableOpacity, StyleSheet } from 'react-native';
import { ActivityIndicator } from 'react-native';
import { useProductList } from '../api/useProducts';
import { useCartStore } from '../stores/useCartStore';
import { usePreferencesStore } from '../stores/usePreferencesStore';
import { useShallow } from 'zustand/shallow';

export function ShopScreen() {
  // Server state: danh sách sản phẩm từ API
  const {
    data,
    isLoading,
    error,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    refetch,
  } = useProductList();

  // Client state: giỏ hàng
  const addToCart = useCartStore((state) => state.addToCart);
  const itemCount = useCartStore((state) => state.getItemCount());

  // Persistent state: cài đặt người dùng
  const theme = usePreferencesStore((state) => state.theme);

  const isDark = theme === 'dark';
  const products = data?.pages.flatMap((page) => page.products) ?? [];

  if (isLoading) {
    return (
      <View style={[styles.center, isDark && styles.darkBg]}>
        <ActivityIndicator size="large" color="#6366f1" />
        <Text style={[styles.loadingText, isDark && styles.darkText]}>
          Đang tải sản phẩm...
        </Text>
      </View>
    );
  }

  if (error) {
    return (
      <View style={[styles.center, isDark && styles.darkBg]}>
        <Text style={styles.errorText}>
          Không thể tải sản phẩm. Vui lòng thử lại.
        </Text>
        <TouchableOpacity onPress={() => refetch()} style={styles.retryButton}>
          <Text style={styles.retryText}>Thử lại</Text>
        </TouchableOpacity>
      </View>
    );
  }

  return (
    <View style={[styles.container, isDark && styles.darkBg]}>
      {/* Header với badge giỏ hàng */}
      <View style={styles.header}>
        <Text style={[styles.title, isDark && styles.darkText]}>
          Cửa hàng
        </Text>
        <View style={styles.cartBadge}>
          <Text style={styles.cartBadgeText}>🛒 {itemCount}</Text>
        </View>
      </View>

      {/* Danh sách sản phẩm */}
      <FlatList
        data={products}
        keyExtractor={(item) => item.id}
        numColumns={2}
        contentContainerStyle={styles.list}
        renderItem={({ item }) => (
          <View style={[styles.productCard, isDark && styles.darkCard]}>
            <Text style={[styles.productName, isDark && styles.darkText]}>
              {item.name}
            </Text>
            <Text style={styles.productPrice}>
              {item.price.toLocaleString('vi-VN')}đ
            </Text>
            <TouchableOpacity
              style={[
                styles.addButton,
                !item.inStock && styles.disabledButton,
              ]}
              disabled={!item.inStock}
              onPress={() =>
                addToCart({
                  productId: item.id,
                  name: item.name,
                  price: item.price,
                  imageUrl: item.imageUrl,
                })
              }
            >
              <Text style={styles.addButtonText}>
                {item.inStock ? 'Thêm vào giỏ' : 'Hết hàng'}
              </Text>
            </TouchableOpacity>
          </View>
        )}
        onEndReached={() => {
          if (hasNextPage && !isFetchingNextPage) {
            fetchNextPage();
          }
        }}
        onEndReachedThreshold={0.5}
        ListFooterComponent={
          isFetchingNextPage ? (
            <ActivityIndicator style={{ padding: 16 }} color="#6366f1" />
          ) : null
        }
        onRefresh={refetch}
        refreshing={isLoading}
      />
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, backgroundColor: '#f9fafb' },
  center: { flex: 1, justifyContent: 'center', alignItems: 'center' },
  darkBg: { backgroundColor: '#111827' },
  darkText: { color: '#f9fafb' },
  darkCard: { backgroundColor: '#1f2937' },
  header: {
    flexDirection: 'row',
    justifyContent: 'space-between',
    alignItems: 'center',
    padding: 16,
    paddingTop: 60,
  },
  title: { fontSize: 28, fontWeight: 'bold', color: '#111827' },
  cartBadge: {
    backgroundColor: '#6366f1',
    borderRadius: 16,
    paddingHorizontal: 12,
    paddingVertical: 6,
  },
  cartBadgeText: { color: '#fff', fontWeight: '600' },
  list: { paddingHorizontal: 8 },
  productCard: {
    flex: 1,
    margin: 8,
    padding: 12,
    backgroundColor: '#fff',
    borderRadius: 12,
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 1 },
    shadowOpacity: 0.1,
    shadowRadius: 3,
    elevation: 2,
  },
  productName: { fontSize: 14, fontWeight: '600', color: '#111827' },
  productPrice: {
    fontSize: 16,
    fontWeight: 'bold',
    color: '#6366f1',
    marginTop: 4,
  },
  addButton: {
    marginTop: 8,
    backgroundColor: '#6366f1',
    borderRadius: 8,
    paddingVertical: 8,
    alignItems: 'center',
  },
  disabledButton: { backgroundColor: '#d1d5db' },
  addButtonText: { color: '#fff', fontWeight: '600', fontSize: 13 },
  loadingText: { marginTop: 12, color: '#6b7280' },
  errorText: { color: '#ef4444', fontSize: 16, textAlign: 'center' },
  retryButton: {
    marginTop: 12,
    paddingHorizontal: 24,
    paddingVertical: 10,
    backgroundColor: '#6366f1',
    borderRadius: 8,
  },
  retryText: { color: '#fff', fontWeight: '600' },
});

Nhìn vào component ShopScreen, bạn có thể thấy rõ ba tầng state hoạt động cùng nhau một cách hài hòa:

  • TanStack Query (useProductList): Quản lý dữ liệu sản phẩm từ API — caching, infinite scroll, pull-to-refresh, loading/error states, tất cả đã được xử lý sẵn
  • Zustand (useCartStore): Quản lý giỏ hàng ở phía client — thêm sản phẩm, đếm số lượng, phản hồi ngay không cần đợi server
  • Zustand + MMKV (usePreferencesStore): Theme setting được persist tự động, user mở lại app vẫn giữ nguyên cài đặt

Mỗi công cụ làm đúng việc của mình, không chồng chéo. Đây chính là kiến trúc hybrid mà cộng đồng React Native 2026 đang áp dụng rộng rãi.

Jotai: Cách tiếp cận Atomic thay thế

Mặc dù Zustand là lựa chọn phổ biến nhất, Jotai xứng đáng được nhắc đến như một giải pháp thay thế xuất sắc — đặc biệt cho các ứng dụng có UI phức tạp cần fine-grained reactivity.

Mô hình Atomic của Jotai

Jotai lấy cảm hứng từ Recoil (Meta) nhưng đơn giản hơn nhiều. Thay vì tạo store chứa tất cả state, bạn tạo các atom — đơn vị state nhỏ nhất, hoàn toàn độc lập:

// atoms/themeAtom.ts
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai';

// Atom cơ bản
export const themeAtom = atom<'light' | 'dark'>('light');

// Derived atom (computed) - tự động cập nhật khi themeAtom thay đổi
export const isDarkAtom = atom((get) => get(themeAtom) === 'dark');

// Atom cho counter
export const counterAtom = atom(0);

// Write-only atom (action)
export const incrementAtom = atom(null, (get, set) => {
  set(counterAtom, get(counterAtom) + 1);
});

// Async atom - fetch data
export const userAtom = atom(async () => {
  const response = await fetch('https://api.example.com/me');
  return response.json();
});

// Sử dụng trong component
function ThemeToggle() {
  const [theme, setTheme] = useAtom(themeAtom);
  const isDark = useAtomValue(isDarkAtom);

  return (
    <TouchableOpacity onPress={() => setTheme(isDark ? 'light' : 'dark')}>
      <Text>Theme hiện tại: {theme}</Text>
    </TouchableOpacity>
  );
}

Khi nào chọn Jotai thay vì Zustand?

Jotai phù hợp hơn trong một số trường hợp cụ thể:

  • Fine-grained reactivity: Khi bạn có hàng chục state nhỏ và cần mỗi component chỉ re-render khi đúng atom của nó thay đổi. Ví dụ: ứng dụng spreadsheet, dashboard phức tạp.
  • Code splitting tự nhiên: Atoms được định nghĩa gần component sử dụng chúng, không cần import từ một store tập trung. Điều này giúp tree-shaking hiệu quả hơn.
  • Derived state phức tạp: Jotai xử lý computed values rất tự nhiên qua derived atoms, tương tự selectors nhưng khai báo rõ ràng hơn.
  • Không cần middleware: Nếu bạn không dùng persist, immer, devtools — những thứ mà Zustand mạnh hơn nhiều.

Tuy nhiên, thành thật mà nói, đối với hầu hết ứng dụng React Native, Zustand vẫn là lựa chọn tốt hơn nhờ hệ sinh thái middleware phong phú — đặc biệt là persist middleware kết hợp với MMKV. Jotai tỏa sáng khi bạn cần atomic model và ứng dụng có UI state cực kỳ phân tán.

Best Practices và những sai lầm thường gặp

Sau khi đã hiểu các công cụ, đây là những nguyên tắc và "bẫy" phổ biến mà mình muốn chia sẻ từ kinh nghiệm thực tế. Mình đã mắc phải hầu hết những lỗi này ít nhất một lần (thường là nhiều hơn).

Những điều nên làm

  • Giữ state ở cấp thấp nhất có thể: Không phải mọi thứ đều cần global state. State chỉ dùng trong một component? Dùng useState. Chia sẻ giữa parent-child? Dùng props. Chia sẻ giữa các nhánh component tree không liên quan? Đó mới là lúc cần Zustand.
  • Tách biệt server state và client state: Đây là nguyên tắc vàng. Dữ liệu từ API phải đi qua TanStack Query, không đổ vào Zustand store.
  • Sử dụng selectors đúng cách: Luôn dùng selector function khi gọi Zustand hook. Khi cần nhiều giá trị, nhớ dùng useShallow.
  • Test store tách biệt: Zustand stores là plain JavaScript, bạn có thể test chúng mà không cần render component. Điều này giúp test nhanh hơn và đáng tin cậy hơn rất nhiều.
// __tests__/useCartStore.test.ts
import { useCartStore } from '../stores/useCartStore';

beforeEach(() => {
  // Reset store về trạng thái ban đầu
  useCartStore.setState({ items: [] });
});

test('addToCart adds new item', () => {
  const { addToCart } = useCartStore.getState();
  addToCart({
    productId: '1',
    name: 'Áo thun',
    price: 150000,
    imageUrl: '/images/ao-thun.jpg',
  });

  const { items } = useCartStore.getState();
  expect(items).toHaveLength(1);
  expect(items[0].quantity).toBe(1);
});

test('addToCart increments quantity for existing item', () => {
  const { addToCart } = useCartStore.getState();
  addToCart({ productId: '1', name: 'Áo thun', price: 150000, imageUrl: '' });
  addToCart({ productId: '1', name: 'Áo thun', price: 150000, imageUrl: '' });

  const { items } = useCartStore.getState();
  expect(items).toHaveLength(1);
  expect(items[0].quantity).toBe(2);
});

Những sai lầm thường gặp

  • Đặt tất cả vào global state: Form input values, animation values, scroll positions — những thứ này KHÔNG nên nằm trong Zustand. Hãy dùng local state với useState hoặc useRef.
  • Dùng Zustand cho server data: Nếu bạn fetch dữ liệu từ API rồi đặt vào Zustand store, bạn đang tự xây dựng một phiên bản TanStack Query kém chất lượng hơn. Hãy dùng đúng công cụ.
  • Bẫy re-render với selector: Trả về object mới từ selector mà không dùng useShallow sẽ khiến component re-render liên tục, ngay cả khi giá trị bên trong không hề thay đổi. Đây là lỗi rất phổ biến.
  • Quên xử lý hydration: Khi dùng Zustand persist, store cần thời gian hydrate từ MMKV. Component render trước khi hydration hoàn tất sẽ thấy giá trị mặc định. Sử dụng onRehydrateStorage callback hoặc kiểm tra useStore.persist.hasHydrated() để xử lý đúng cách.
  • Store quá lớn: Một store Zustand chứa 50 properties và 30 actions? Đó là dấu hiệu rõ ràng bạn cần tách nhỏ. Mỗi store nên quản lý một domain rõ ràng, gọn gàng.

Kết luận: Stack quản lý state cho React Native 2026

Quản lý state trong React Native năm 2026 không còn là cuộc chiến giữa các thư viện "one-size-fits-all" nữa. Thay vào đó, cộng đồng đã đi đến một đồng thuận khá rõ ràng: dùng đúng công cụ cho đúng loại state.

Stack hiện đại gồm ba thành phần chính:

  1. Zustand cho client state — đơn giản, nhẹ, không cần Provider, TypeScript tuyệt vời
  2. TanStack Query cho server state — caching, deduplication, background refetch, optimistic updates
  3. MMKV cho persistent state — nhanh hơn AsyncStorage 30 lần, kết hợp hoàn hảo với Zustand persist

Ba công cụ này bổ sung cho nhau chứ không chồng chéo. Khi kết hợp, chúng tạo nên kiến trúc vừa đơn giản để hiểu, vừa đủ mạnh để xây dựng ứng dụng phức tạp ở production scale.

Nhìn về phía trước, xu hướng hybrid approach này sẽ tiếp tục được củng cố. React 19 với các tính năng như use() hook và Server Components (khi React Native hỗ trợ đầy đủ) sẽ mở ra thêm những cách quản lý state mới. Nhưng nền tảng đã rõ ràng: hiểu loại state, chọn đúng công cụ, và giữ kiến trúc đơn giản nhất có thể.

Nếu bạn đang bắt đầu dự án React Native mới trong 2026, hãy thử áp dụng bộ ba Zustand + TanStack Query + MMKV. Mình tin bạn sẽ ngạc nhiên vì code trở nên sạch sẽ và dễ bảo trì hơn bao nhiêu so với cách tiếp cận "Redux cho tất cả" ngày trước.

Về Tác Giả Editorial Team

Our team of expert writers and editors.