Hone logo
Hone
Problems

React Persistent State Manager

This challenge focuses on implementing persistent data structures within a React application using TypeScript. You'll build a custom hook that allows components to manage state which automatically persists across browser sessions and can be efficiently updated. This is crucial for applications where user progress or settings need to be preserved.

Problem Description

Your task is to create a reusable React hook, usePersistentState, that behaves similarly to React.useState but with the added functionality of persisting its value to localStorage and enabling efficient, immutable updates.

What needs to be achieved:

  1. State Management: The hook should manage a piece of state within a React component.
  2. Persistence: The state's value should be automatically saved to localStorage whenever it changes.
  3. Initialization: When the hook is first used in a component, it should attempt to load the initial state from localStorage. If no value is found in localStorage for the given key, it should use a provided default value.
  4. Immutable Updates: The setState function returned by the hook must ensure that state updates are immutable. This means that directly modifying the state object or array should not be allowed; instead, new objects or arrays should be created for updates. This is a core principle of persistent data structures.
  5. Type Safety: The hook should be strongly typed using TypeScript, accepting a generic type for the state.

Key Requirements:

  • Create a custom React hook usePersistentState<T>(key: string, defaultValue: T): [T, (updater: T | ((prevState: T) => T)) => void].
  • key: A unique string identifier used to store and retrieve the state from localStorage.
  • defaultValue: The value to use if no data is found in localStorage for the given key.
  • The hook should return a tuple [state, setState], mirroring React.useState.
  • The setState function should accept either a new value or an updater function (like React.useState).
  • When setState is called, the new state must be serialized (e.g., using JSON.stringify) and saved to localStorage under the provided key.
  • On initial render, the hook should deserialize (e.g., using JSON.parse) the value from localStorage for the given key. If localStorage access fails or no value is found, defaultValue should be used.
  • The setState function must enforce immutability. For objects and arrays, this means that the updater function or the new value provided to setState should always be a new instance if modifications are intended. The hook itself should not perform deep cloning but rather expect the user to provide new instances.

Expected Behavior:

  • Initial Load: A component using usePersistentState('userSettings', { theme: 'light', fontSize: 16 }) will first check localStorage for the key 'userSettings'.
    • If found, it loads the stored object.
    • If not found, it uses { theme: 'light', fontSize: 16 }.
  • State Update: Calling setState(prevState => ({ ...prevState, theme: 'dark' })) will:
    • Create a new object { theme: 'dark', fontSize: 16 }.
    • Update the component's state with this new object.
    • Serialize and save this new object to localStorage under 'userSettings'.
  • Page Refresh/New Session: When the application reloads or is opened in a new browser tab/session, the usePersistentState hook will again check localStorage for 'userSettings' and load the last saved value.

Edge Cases:

  • localStorage Unavailable: The application might be running in an environment where localStorage is not available (e.g., server-side rendering without hydration, or privacy-focused browsers blocking it). The hook should gracefully handle this by falling back to the defaultValue without crashing.
  • Invalid localStorage Data: If the data stored in localStorage is corrupted or not valid JSON, the JSON.parse operation might fail. The hook should catch these errors and fall back to the defaultValue.
  • Non-JSON Serializable Data: While the primary focus is on JSON-serializable data, consider that users might attempt to store complex objects. The serialization/deserialization assumes JSON compatibility.
  • Concurrent Updates: While not a primary focus of this challenge, be mindful that multiple components could potentially update the same persistent state. The localStorage mechanism inherently provides a single source of truth.

Examples

Example 1: Basic Object Persistence

// In MyComponent.tsx

import React from 'react';
import usePersistentState from './usePersistentState'; // Assuming the hook is in this file

interface UserSettings {
  theme: 'light' | 'dark';
  fontSize: number;
}

function MyComponent() {
  const [settings, setSettings] = usePersistentState<UserSettings>(
    'userSettings',
    { theme: 'light', fontSize: 16 }
  );

  const toggleTheme = () => {
    // Enforcing immutability by creating a new object
    setSettings(prevSettings => ({
      ...prevSettings,
      theme: prevSettings.theme === 'light' ? 'dark' : 'light',
    }));
  };

  const increaseFontSize = () => {
    // Enforcing immutability by creating a new object
    setSettings(prevSettings => ({
      ...prevSettings,
      fontSize: prevSettings.fontSize + 1,
    }));
  };

  return (
    <div>
      <h1>User Settings</h1>
      <p>Theme: {settings.theme}</p>
      <p>Font Size: {settings.fontSize}</p>
      <button onClick={toggleTheme}>Toggle Theme</button>
      <button onClick={increaseFontSize}>Increase Font Size</button>
    </div>
  );
}

Explanation: When MyComponent first renders, it loads 'userSettings' from localStorage. If it doesn't exist, it defaults to { theme: 'light', fontSize: 16 }. Clicking "Toggle Theme" calls setSettings with an updater function. This function receives the previous settings object, creates a new object with the updated theme, and this new object is stored in state and localStorage. Clicking "Increase Font Size" does the same, creating a new object with an incremented fontSize. If the page is reloaded, the component will render with the last saved theme and fontSize from localStorage.

Example 2: Array Persistence

// In TodoListComponent.tsx

import React from 'react';
import usePersistentState from './usePersistentState'; // Assuming the hook is in this file

interface TodoItem {
  id: number;
  text: string;
  completed: boolean;
}

function TodoListComponent() {
  const [todos, setTodos] = usePersistentState<TodoItem[]>('todoList', []);
  const [newTodoText, setNewTodoText] = React.useState('');
  const nextId = React.useRef(
    todos.length > 0 ? Math.max(...todos.map(t => t.id)) + 1 : 1
  );

  const addTodo = () => {
    if (newTodoText.trim() === '') return;
    const newTodo: TodoItem = {
      id: nextId.current,
      text: newTodoText.trim(),
      completed: false,
    };
    // Enforcing immutability by creating a new array
    setTodos([...todos, newTodo]);
    setNewTodoText('');
    nextId.current++;
  };

  const toggleTodoComplete = (id: number) => {
    // Enforcing immutability by mapping and creating new objects/array
    setTodos(prevTodos =>
      prevTodos.map(todo =>
        todo.id === id ? { ...todo, completed: !todo.completed } : todo
      )
    );
  };

  return (
    <div>
      <h2>Todo List</h2>
      <input
        type="text"
        value={newTodoText}
        onChange={(e) => setNewTodoText(e.target.value)}
        placeholder="Add new todo"
      />
      <button onClick={addTodo}>Add</button>
      <ul>
        {todos.map((todo) => (
          <li
            key={todo.id}
            style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}
          >
            {todo.text}
            <button onClick={() => toggleTodoComplete(todo.id)}>
              {todo.completed ? 'Undo' : 'Complete'}
            </button>
          </li>
        ))}
      </ul>
    </div>
  );
}

Explanation: This component manages a list of todos. usePersistentState('todoList', []) initializes the state from localStorage or uses an empty array. addTodo creates a newTodo and uses the spread syntax [...todos, newTodo] to create a new array containing the old todos plus the new one. This new array is then passed to setTodos. toggleTodoComplete uses .map() which inherently creates a new array. Inside the map, if a todo's ID matches, a new todo object { ...todo, completed: !todo.completed } is created to ensure immutability.

Example 3: Edge Case - localStorage Unavailable

Imagine the usePersistentState hook is designed to wrap localStorage calls in try...catch blocks.

// Assume localStorage is mocked or unavailable in this scenario

function App() {
  // If localStorage.getItem('testKey') throws or returns undefined
  // the defaultValue will be used.
  const [data, setData] = usePersistentState<string[]>('testKey', ['default', 'values']);

  return (
    <div>
      <p>Data: {data.join(', ')}</p>
      <button onClick={() => setData([...data, 'new'])}>Add Item</button>
    </div>
  );
}

Explanation: If localStorage is disabled or inaccessible, usePersistentState will not be able to read 'testKey'. It will then use the provided defaultValue which is ['default', 'values']. Any subsequent calls to setData will still work correctly, managing the state within the component, but without persisting it to localStorage.

Constraints

  • The usePersistentState hook must be implemented in TypeScript.
  • The hook must handle string, number, boolean, object, and array types for state.
  • All operations involving localStorage (reading and writing) must be wrapped in try...catch blocks to gracefully handle potential errors (e.g., localStorage being full, disabled, or in an SSR context).
  • The setState function provided by the hook must adhere to the principle of immutability for complex types (objects and arrays). The hook itself should not deep clone; it relies on the user providing new instances during updates.
  • Performance: While not strictly limited by numbers, the solution should avoid unnecessary re-renders and deep cloning within the hook itself. The overhead of JSON.stringify and JSON.parse is acceptable for this challenge.

Notes

  • Consider how to handle the initial load from localStorage. You'll likely want to do this only once when the hook mounts.
  • The setState function needs to be robust enough to handle both direct value assignments and functional updates (e.g., setState(prev => newState)).
  • Think about how to serialize and deserialize your state. JSON.stringify and JSON.parse are the standard choices for most JavaScript data types.
  • Remember that localStorage stores data as strings. You'll need to serialize your state before storing and deserialize it after retrieving.
  • This challenge is a great opportunity to understand the benefits of immutable data structures in frontend development, particularly for state management and performance optimizations.
Loading editor...
typescript