•9 min read

Xây dựng một useLocalStorage Hook an toàn kiểu trong React

Xây dựng một useLocalStorage Hook an toàn kiểu trong React

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.

Audio Briefing
0:00 / 0:00

Những cạm bẫy của các triển khai đơn giản

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:

  1. Không khớp khi Hydrate trong SSR: Trong một framework như Next.js, máy chủ không có quyền truy cập vào đối tượ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.
  2. Thiếu an toàn kiểu: Việc thiếu TypeScript có nghĩa là storedValue có kiểu any ngầm định, làm mất đi lợi ích của phân tích tĩnh.
  3. Trạng thái cũ giữa các tab: Nếu người dùng cập nhật giá trị 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.
  4. Phân tích cú pháp JSON không hiệu quả: Nó không xử lý các cấu trúc có thể tuần tự hóa phức tạp một cách an toàn hoặc xác thực các kiểu thời gian chạy.
Advertisement

Bước 1: Thực thi an toàn kiểu nghiêm ngặt với Generics nâng cao

Đầ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.

Bước 2: Chinh phục Hydration SSR trong Next.js

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ữ.

Bước 3: Triển khai Setter an toàn kiểu

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ủ.

Advertisement

Bước 4: Đồng bộ hóa giữa các tab thông qua Storage API

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.

Bước 5: Tinh chỉnh nâng cao với useSyncExternalStore (React 18+)

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ý.

Kết luận

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.

Bạn cũng có thể thích

Share this article:

Stay Updated

Get the latest posts delivered straight to your inbox.

Free Developer Utilities

Free In-Browser Developer Tools

Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.

Explore Tools
Advertisement