Next.js: State Management với Ecosy

Hướng dẫn tích hợp @ecosy/store vào Next.js App Router từ A đến Z. Quản lý state gọn nhẹ, type-safe, hiệu năng cao với kiến trúc Server/Client Hydration chuẩn mực thay thế hoàn hảo cho Redux.

14/05/2026
8 phút đọc
1

Cài đặt Packages

Ecosy là một giải pháp quản lý trạng thái (State Management) gọn nhẹ, có bộ API học hỏi từ Redux Toolkit nhưng được tối giản hóa tối đa. Điểm đặc biệt của Ecosy là không yêu cầu phải bọc ứng dụng bằng thẻ <Provider> truyền thống.

yarn add @ecosy/store @ecosy/react
Tại sao lại là Ecosy?

Trong môi trường Next.js App Router (nơi ranh giới giữa Server và Client rất rạch ròi), việc dùng Context API hoặc Redux đôi khi gây đau đầu ở khâu Hydration và Provider wrapper. Ecosy giải quyết bài toán này bằng cách đưa Store ra ngoài React Tree (External Store). Nhờ sử dụng cơ chế Pub/Sub (Publisher/Subscriber) kết hợp với các custom hook cơ bản (như useState, useEffect), các Component có thể tự động lắng nghe thay đổi (subscribe) và re-render một cách độc lập mà không cần bọc Provider rườm rà.

2

Khởi tạo Store chuẩn SSR

Trong Next.js, mỗi lần Hot Module Replacement (HMR) chạy ở môi trường Dev, file bị load lại có thể vô tình khởi tạo lại (reset) Store. Để giải quyết, chúng ta sử dụng thủ thuật lưu Store vào globalThis.

Tạo file store/index.ts:

import { combineSlices, configureStore } from "@ecosy/store";
import { blogSlice } from "./slices/blog";

const createStore = () => configureStore({
  slices: combineSlices({
    blog: blogSlice,
  })
});

// Giữ type an toàn
declare global {
  var __ecosy_store: ReturnType<typeof createStore> | undefined;
}

// Khởi tạo hoặc tái sử dụng store
const configured = globalThis.__ecosy_store ?? createStore();

if (process.env.NODE_ENV !== "production") {
  globalThis.__ecosy_store = configured;
}

export type RootState = ReturnType<typeof configured.getState>;

export const store = configured.store;
export const hydrate = configured.hydrate;
export const dispatch = configured.dispatch;
export const getState = configured.getState;
3

Định nghĩa Slices và Actions

Khái niệm Slice trong Ecosy gần như tương đồng với Redux Toolkit.

Tạo Slice

File store/slices/blog.ts:

import { createSlice, PayloadAction } from "@ecosy/store";

export interface BlogState {
  allBlogs: Record<string, any>;
}

const initialState: BlogState = {
  allBlogs: {},
};

export const blogSlice = createSlice({
  name: "blog",
  initialState,
  reducers: {
    setBlogs: (state, action: PayloadAction<Record<string, any>>) => {
      state.allBlogs = action.payload;
    },
  },
});
Gom nhóm Actions

File store/actions.ts:

import { blogSlice } from "./slices/blog";

export const actions = {
  blog: blogSlice.actions,
};
4

Xây dựng Selectors

Bởi vì App Router có Server Components và Client Components, cách đọc state ở 2 môi trường này là khác nhau.

Client Selector

File store/selector/client.ts. Sử dụng Hook để Component có thể re-render khi State đổi.

"use client";
import { createStoreOrder } from "@ecosy/react";
import { RootState, store } from "../index";

export type Selector = <Selected>(selector: (state: RootState) => Selected) => Selected;
export const useSelector: Selector = createStoreOrder(store);
Server Selector

File store/selector/server.ts. Server không có khái niệm hook hay vòng đời (lifecycle), do đó ta chỉ cần lấy snapshot giá trị hiện tại.

import { getState, RootState } from "../index";

export function select<T>(selector: (state: RootState) => T): T {
  return selector(getState());
}
5

Kỹ thuật Hydration (Server -> Client)

Đây là phần tinh túy nhất của kiến trúc: Lấy dữ liệu (Data Fetching) ở Server và "Bơm" (Hydrate) nó vào Client Store để SEO tốt và tránh nhảy giao diện (FOUC).

Server Hydrator

File app/hydrate/server.tsx: Hàm này chạy hoàn toàn trên server.

import { HydrateClient } from "./client";
import { getState, dispatch } from "@/store";
import { actions } from "@/store/actions";

export async function HydrateServer({ children }) {
  // Lấy dữ liệu bất đồng bộ ở server
  const allBlogs = await fetchAllBlogs();
  
  // Lưu vào Server Store
  dispatch(actions.blog.setBlogs(allBlogs));

  // Gói gọn state hiện tại của Server truyền qua props cho Client
  return (
    <HydrateClient {...getState()}>
      {children}
    </HydrateClient>
  );
}
Client Hydrator

File app/hydrate/client.tsx: Nhận state từ Server và khởi tạo Client Store ngay trong chu kỳ render đầu tiên.

"use client";
import { hydrate, RootState } from "@/store";
import { PropsWithChildren, useRef } from "react";

export type HydrateClientProps = RootState;

export function HydrateClient(props: PropsWithChildren<HydrateClientProps>) {
  const { children, ...rest } = props;
  
  // Dùng useRef để giữ ổn định reference state, đặc biệt hữu ích khi Hot Reload
  const stateRef = useRef(rest);
  stateRef.current = rest;

  // Bơm state vào Client Store
  hydrate(stateRef.current);
  
  return children;
}
Quan trọng

Bạn cần bọc <HydrateServer> ở ngoài cùng của Layout ứng dụng (trong app/layout.tsx) để toàn bộ cây Component phía trong đều nhận được state đồng nhất từ Server truyền xuống.

6

Truy xuất State

Mọi thứ đã hoàn tất! Bây giờ ở bất kỳ Client Component nào, bạn chỉ việc gọi useSelector.

"use client";
import { useSelector } from "@/store/selector/client";

export function BlogList() {
  // Tự động nhận type an toàn, render lại khi allBlogs thay đổi
  const allBlogsMap = useSelector((state) => state.blog.allBlogs);
  const blogs = Object.values(allBlogsMap);

  return (
    <div>
      {blogs.map(blog => (
        <h3 key={blog.slug}>{blog.title}</h3>
      ))}
    </div>
  );
}
DONE ✔️ So sánh với Redux
  • Không cần <Provider store={store}> bao bọc App.
  • Hàm hydrate gọi trực tiếp rất minh bạch và không phụ thuộc vào React Context.
  • Setup type cực kỳ nhanh chóng và không rườm rà boilerplate.
7

Isolated Feature Store (Mô hình Local Store)

Ngoài việc dùng Ecosy làm Global Store (như Redux), thư viện này cũng hỗ trợ thiết kế các Local Store độc lập cho từng tính năng (giống như cách dùng của Zustand). Phương pháp này cực kỳ hữu ích khi bạn muốn quản lý State rườm rà ở một trang cụ thể (ví dụ: state tìm kiếm của trang danh sách bài viết) mà không muốn làm "rác" Global Store.

Thay vì combineSlices và nhúng vào `store/index.ts`, bạn có thể tạo thẳng một Store đơn giản ngay cạnh Component:

File app/blog/(list)/store.ts:

import { createStoreOrder } from "@ecosy/react";
import { createStore, PayloadAction } from "@ecosy/store";

export interface BlogSearchState {
  search: string;
}

const initialState: BlogSearchState = {
  search: ""
};

// 1. Tạo Store độc lập (không cần thông qua Global Store)
export const { store, actions } = createStore({
  name: "blogsearch",
  initialState,
  reducers: {
    setSearch(state, action: PayloadAction<string>) {
      state.search = action.payload;
    }
  },
});

// 2. Export riêng một hook useSelector cho Store này
const useSelector = createStoreOrder<BlogSearchState, typeof store>(store);

export function useBlogSearch() {
  return useSelector((state) => state.search);
}

Cách sử dụng trong Component cực kỳ sạch sẽ và đóng gói hoàn hảo:

"use client";
import { actions, useBlogSearch } from "./store";

export function BlogSearch() {
  const search = useBlogSearch();

  return (
    <input
      value={search}
      onChange={(e) => actions.setSearch(e.target.value)}
      placeholder="Tìm kiếm..."
    />
  );
}
Lưu ý về Vòng đời (Lifecycle) của Local Store

Vì Store được khởi tạo ở cấp độ Module (bên ngoài Component), dữ liệu của nó sẽ tồn tại vĩnh viễn (persist) chừng nào tab trình duyệt chưa bị đóng. Điều này cực kỳ có lợi nếu bạn muốn giữ lại chuỗi tìm kiếm khi User bấm "Back" từ trang chi tiết quay về trang danh sách.

Hoặc một "Edge-case" trải nghiệm cực mượt (Smooth UX) đó là: khi User mở Dialog nhập Form dài, lỡ tay bấm tắt Dialog đi, lát sau mở lại dữ liệu nhập dở vẫn còn y nguyên (vì Store không bị hủy theo Component). Rất tuyệt vời!

Tuy nhiên, nếu ứng dụng của bạn bắt buộc phải "dọn dẹp" sạch sẽ Form mỗi khi tắt Dialog (tránh dính dữ liệu cũ), bạn sẽ phải tự gọi hàm Reset State (ví dụ actions.reset()) trong sự kiện đóng Dialog hoặc trong useEffect cleanup nhé.