Mục lục
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
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à.
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;
Đị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 SliceFile 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,
};
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 SelectorFile 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());
}
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 HydratorFile 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;
}
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.
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>
);
}
- Không cần
<Provider store={store}>bao bọc App. - Hàm
hydrategọ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.
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..."
/>
);
}
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é.