
Quản lý trạng thái trong React là một con đường quen thuộc, nhưng việc thu hẹp khoảng cách giữa trạng thái component dễ thay đổi và bộ nhớ trình duyệt bền vững thường gây ra những lỗi khó nhận thấy. Một cách triển khai useLocalStorage đơn giản có thể hoạt động tốt cho một bản thử nghiệm nhanh, nhưng nó nhanh chóng sụp đổ trong các ứng dụng Next.js cấp độ sản xuất do lỗi không khớp khi hydrate Server-Side Rendering (SSR), lỗi suy luận TypeScript phức tạp và các vấn đề đồng bộ hóa giữa các tab.
Trong bài viết chuyên sâu này, chúng ta sẽ xây dựng một hook useLocalStorage có tính kỹ thuật cao, mạnh mẽ và an toàn kiểu hoàn toàn. Chúng ta sẽ vượt ra ngoài những kiến thức cơ bản được tìm thấy trong các hướng dẫn tiêu chuẩn, tập trung nghiêm ngặt vào các ràng buộc generic nâng cao của TypeScript, xử lý lỗi nghiêm ngặt, an toàn khi hydrate SSR và đồng bộ hóa trạng thái theo hướng sự kiện trên nhiều ngữ cảnh duyệt web.
Hầu hết các hướng dẫn giới thiệu đều cung cấp một hook useLocalStorage trông như thế này:
import { useState } from 'react';
export function useNaiveLocalStorage(key, initialValue) {
const [storedValue, setStoredValue] = useState(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
return initialValue;
}
});
const setValue = (value) => {
setStoredValue(value);
window.localStorage.setItem(key, JSON.stringify(value));
};
return [storedValue, setValue];
}
Mặc dù có chức năng trên một Single Page Application (SPA) cơ bản, cách triển khai này ẩn chứa những lỗi kiến trúc nghiêm trọng:
window. Nếu kết xuất ban đầu trên client khác với kết xuất của máy chủ (vì client đọc một giá trị khác từ localStorage), React sẽ ném lỗi không khớp khi hydrate, có khả năng làm hỏng giao diện người dùng của bạn.storedValue có kiểu any ngầm định, làm mất đi lợi ích của phân tích tĩnh.localStorage trong một tab khác, hook này sẽ không phản ứng với nó, dẫn đến trạng thái giao diện người dùng không nhất quán.Đầu tiên, hãy thiết lập một nền tảng TypeScript mạnh mẽ. Chúng ta muốn hook của mình tự động suy luận kiểu của initialValue và thực thi kiểu đó khi đặt các giá trị mới. Chúng ta có thể đạt được điều này bằng cách sử dụng tham số kiểu generic <T>.
Hơn nữa, useState cho phép truyền một hàm cập nhật (prev: T) => T. Hook tùy chỉnh của chúng ta phải hỗ trợ chữ ký chính xác này để giữ nguyên tính chất của các nhà phát triển React.
import { useState, useCallback } from 'react';
// Define the type for our updater function or raw value
type SetValue<T> = React.Dispatch<React.SetStateAction<T>>;
export function useLocalStorage<T>(
key: string,
initialValue: T
): [T, SetValue<T>] {
// Implementation details follow...
}
Bằng cách định nghĩa SetStateAction<T>, chúng ta hướng dẫn TypeScript rằng hàm setValue có thể chấp nhận một giá trị mới thuộc kiểu T hoặc một hàm callback nhận giá trị trước đó và trả về một giá trị mới thuộc kiểu T. Điều này phản ánh chữ ký useState gốc của React một cách hoàn hảo.
Các vấn đề về hydration xảy ra khi HTML được máy chủ kết xuất khác với HTML được client kết xuất lần đầu. Để khắc phục điều này, chúng ta phải đảm bảo rằng kết xuất ban đầu luôn sử dụng initialValue (mà máy chủ cũng biết), và chỉ đọc từ localStorage sau khi component được mount.
Chúng ta sử dụng useEffect (hoặc useSyncExternalStore trong React hiện đại, mặc dù useEffect kết hợp với một cờ trạng thái rất minh họa ở đây) để trì hoãn việc đọc bộ nhớ.
import { useState, useEffect, useCallback } from 'react';
type SetValue<T> = React.Dispatch<React.SetStateAction<T>>;
export function useLocalStorage<T>(
key: string,
initialValue: T
): [T, SetValue<T>] {
// 1. Initialize with the exact initial value to guarantee SSR hydration parity
const [storedValue, setStoredValue] = useState<T>(initialValue);
const [isMounted, setIsMounted] = useState(false);
// 2. Read from localStorage once the component mounts on the client
useEffect(() => {
setIsMounted(true);
try {
const item = window.localStorage.getItem(key);
if (item !== null) {
setStoredValue(JSON.parse(item));
}
} catch (error) {
console.warn(`Error reading localStorage key "${key}":`, error);
}
}, [key]);
// ... setValue implementation
}
Bằng cách giới thiệu cờ isMounted hoặc trì hoãn việc đọc, lần chạy đầu tiên của cây React khớp chính xác với máy chủ. Chỉ sau khi hydration hoàn tất, hiệu ứng mới chạy, đọc giá trị local storage và kích hoạt kết xuất lại với dữ liệu đã được lưu trữ.
Hàm setter cần xử lý cả giá trị trực tiếp và các hàm cập nhật. Chúng ta sẽ sử dụng useCallback để duy trì một tham chiếu ổn định, ngăn chặn việc kết xuất lại không cần thiết trong các component con.
const setValue: SetValue<T> = useCallback(
(value) => {
try {
// Allow value to be a function so we have the same API as useState
setStoredValue((prev) => {
const valueToStore =
value instanceof Function ? value(prev) : value;
if (typeof window !== 'undefined') {
window.localStorage.setItem(key, JSON.stringify(valueToStore));
}
return valueToStore;
});
} catch (error) {
console.warn(`Error setting localStorage key "${key}":`, error);
}
},
[key]
);
Hãy chú ý cách chúng ta kiểm tra value instanceof Function. Bởi vì kiểu SetStateAction của TypeScript cho phép một hàm, chúng ta phải xác định an toàn xem giá trị được cung cấp có phải là một callback cập nhật hay không và thực thi nó dựa trên trạng thái prev nếu có. Chúng ta cũng bao gồm một kiểm tra an toàn typeof window !== 'undefined' để ngăn chặn các sự cố SSR nếu setter vô tình được gọi trong quá trình đánh giá của máy chủ.
Một ứng dụng web thực sự hiện đại phải đồng bộ hóa trạng thái giữa nhiều tab. Nếu người dùng bật "Chế độ tối" trong Tab A, Tab B phải ngay lập tức phản ánh sự thay đổi này. Sự kiện storage gốc của trình duyệt sẽ kích hoạt bất cứ khi nào một khu vực lưu trữ bị thay đổi từ một tài liệu khác.
Chúng ta sẽ gắn một trình lắng nghe sự kiện để nắm bắt những thay đổi bên ngoài này.
useEffect(() => {
const handleStorageChange = (event: StorageEvent) => {
if (event.key === key && event.newValue !== null) {
try {
setStoredValue(JSON.parse(event.newValue));
} catch (error) {
console.warn(`Error parsing storage change for key "${key}":`, error);
}
} else if (event.key === key && event.newValue === null) {
// Handle deletion from another tab
setStoredValue(initialValue);
}
};
// Listen only on the client
if (typeof window !== 'undefined') {
window.addEventListener('storage', handleStorageChange);
}
return () => {
if (typeof window !== 'undefined') {
window.removeEventListener('storage', handleStorageChange);
}
};
}, [key, initialValue]);
Cơ chế đồng bộ hóa này đảm bảo trạng thái ứng dụng của bạn luôn nhất quán trên toàn cầu, mà không yêu cầu thăm dò tốn kém hoặc kiến trúc websocket phức tạp.
Mặc dù cách triển khai trên rất mạnh mẽ, React 18 đã giới thiệu useSyncExternalStore, được xây dựng có mục đích để đăng ký các nguồn dữ liệu bên ngoài như localStorage. Nó vốn dĩ giải quyết các vấn đề xé hình khi kết xuất đồng thời.
Đây là một cái nhìn thoáng qua về cách bạn có thể cấu trúc cách triển khai hiện đại, tối ưu:
import { useSyncExternalStore, useCallback } from 'react';
function subscribe(callback: () => void) {
window.addEventListener('storage', callback);
return () => window.removeEventListener('storage', callback);
}
export function useModernLocalStorage<T>(key: string, initialValue: T): [T, (val: T | ((prev: T) => T)) => void] {
const getSnapshot = () => window.localStorage.getItem(key);
const getServerSnapshot = () => null; // Prevent SSR mismatch
const store = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
const value = store !== null ? (JSON.parse(store) as T) : initialValue;
const setValue = useCallback(
(newValue: T | ((prev: T) => T)) => {
const valueToStore = newValue instanceof Function ? newValue(value) : newValue;
window.localStorage.setItem(key, JSON.stringify(valueToStore));
// Manually dispatch storage event for same-tab reactivity if needed,
// though useSyncExternalStore handles cross-tab out of the box.
window.dispatchEvent(new Event('storage'));
},
[key, value]
);
return [value, setValue];
}
Cách tiếp cận useSyncExternalStore này là đỉnh cao của đồng bộ hóa dữ liệu React hiện tại, cung cấp sự tích hợp liền mạch với các tính năng đồng thời và loại bỏ nhu cầu về các chuỗi useEffect phức tạp để quản lý đăng ký.
Một developer triển khai hook `useNaiveLocalStorage` trong ứng dụng Next.js. Họ nhận thấy rằng khi tải trang ban đầu, UI nhấp nháy nhanh một trạng thái không chính xác trước khi ổn định về trạng thái đúng, và đôi khi React đưa ra cảnh báo hydration mismatch. Đâu là nguyên nhân trực tiếp nhất của hành vi này?
Xây dựng một hook useLocalStorage sẵn sàng cho sản xuất vượt xa việc gói gọn localStorage.setItem. Bằng cách gõ nghiêm ngặt các generic của chúng ta, xử lý rõ ràng giai đoạn hydrate SSR và triển khai đồng bộ hóa giữa các tab, chúng ta tạo ra một công cụ quản lý trạng thái linh hoạt, có thể mở rộng.
Cho dù bạn chọn mẫu useEffect tiêu chuẩn hay tận dụng API useSyncExternalStore hiện đại, việc giải quyết các trường hợp biên phức tạp này sẽ cải thiện đáng kể sự ổn định của các ứng dụng Next.js và trải nghiệm của nhà phát triển trong nhóm của bạn.
Làm sạch log AI CLI, tạo biểu thức cron, giải mã JWT và tính quyền chmod offline.

Hướng dẫn sửa triệt để lỗi Hydration failed (#418), Text content does not match server-rendered HTML, lỗi dark mode flash next-themes và localStorage trong Next.js.
Đọc tiếp
Hướng dẫn toàn diện về React 19 Actions trong thực tế: useActionState, useOptimistic và khả năng phục hồi của Server Action với kiến trúc cấp độ sản phẩm và ví dụ mã.
Đọc tiếp
Nắm vững tối ưu hóa hiệu suất custom React hook bằng cách sử dụng stable ref caching, listener batching, memoized selectors và các kỹ thuật profiler.
Đọc tiếp