Calendar apps make their view toggle sticky: switch from "week" to "month", refresh, and the month view stays selected. Form controls rarely behave that way, which is why a currency field can ask the same user to pick CAD over USD month after month. The fix in React is a custom hook that wraps useState and persists the value, so stickiness is something you get for free wherever it's needed.

The hook

function useStickyState(defaultValue, key) {
  const [value, setValue] = React.useState(() => {
    const stickyValue = window.localStorage.getItem(key);

    return stickyValue !== null
      ? JSON.parse(stickyValue)
      : defaultValue;
  });

  React.useEffect(() => {
    window.localStorage.setItem(key, JSON.stringify(value));
  }, [key, value]);

  return [value, setValue];
}

A counter that keeps its count across reloads is enough to demonstrate the behaviour:

import React from 'react';

function useStickyState(defaultValue, name) {
  const [value, setValue] = React.useState(() => {
    if (
      typeof window === 'undefined' ||
      !window.localStorage
    ) {
      return defaultValue;
    }

    const persistedValue =
      window.localStorage.getItem(name);

    return persistedValue !== null
      ? JSON.parse(persistedValue)
      : defaultValue;
  });

  React.useEffect(() => {
    window.localStorage.setItem(
      name,
      JSON.stringify(value)
    );
  }, [name, value]);

  return [value, setValue];
}

function App() {
  const [count, setCount] = useStickyState(
    0,
    'count'
  );

  return (
    <div className="App">
      <h1>Counter</h1>
      <p>Current count: {count}</p>
      <button onClick={() => setCount(count + 1)}>
        Increment
      </button>
    </div>
  );
}

export default App;

Adopting it in a component

The hook makes one assumption, and it holds for most React code: the value behind a form input lives in React state. A plain toggle looks like this:

const CalendarView = () => {
  const [mode, setMode] = React.useState('day');

  return (
    <>
      <select onChange={ev => setMode(ev.target.value)}>
        <option value="day">Day</option>
        <option value="week">Week</option>
        <option value="month">Month</option>
      </select>

      {/* Calendar stuff here */}
    </>
  )
}

The sticky version is the same component with the hook swapped out:

const CalendarView = () => {
  const [mode, setMode] = useStickyState('day', 'calendar-view');

  // Everything else unchanged
}

Where useState accepts a single argument, useStickyState takes two. The extra argument is the localStorage key used to read and write the persisted value. It has to be unique, but its actual text carries no meaning.

What the hook does internally

Reading the stored value during initialization

The hook is essentially a wrapper around useState with a couple of additions. The first is lazy initialization, which lets a function be passed to useState in place of a value; React runs that function only on the first render, when the state is created.

const [value, setValue] = React.useState(() => {
  const stickyValue =
    window.localStorage.getItem(key);

  return stickyValue !== null
    ? JSON.parse(stickyValue)
    : defaultValue;
});

That callback checks localStorage. A value found there becomes the initial state; when nothing is stored, the default passed to the hook is used instead — "day" in the example above.

Writing changes back

The second addition keeps the two sides in sync: whenever the state value changes, localStorage is updated, which useEffect handles.

React.useEffect(() => {
  window.localStorage.setItem(name, JSON.stringify(value));
}, [name, value]);

The pattern is a compact illustration of custom hooks as a way to invent your own APIs. Packages such as use-persisted-state and react-hooks already solve this problem, but working through the implementation yourself has its own value.