npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@platform-storage/react

v0.1.0

Published

React hooks for platform-storage: read and write schema-validated storage from a component, on the client and across a server render.

Readme

@platform-storage/react

npm version License: MIT

React hooks for platform-storage: read and write a schema-validated storage from a component, and render it on a server without a mismatch.

Installation

Install this alongside the platform package you already use, rather than instead of it. Unlike the platform packages, this one does not re-export the core API: the schema, the storage and the errors keep coming from where they came from.

# Install with npm
npm install @platform-storage/web @platform-storage/react

# Install with pnpm
pnpm add @platform-storage/web @platform-storage/react

# Install with Yarn
yarn add @platform-storage/web @platform-storage/react

# Install with Bun
bun add @platform-storage/web @platform-storage/react

Swap @platform-storage/web for @platform-storage/webextension or @platform-storage/react-native as your platform requires. React 18 or newer is a peer dependency.

Quick Start

import * as z from "zod";
import { createLocalStorage, defineStorageSchema } from "@platform-storage/web";
import { useStorageValue } from "@platform-storage/react";

const schema = defineStorageSchema({
  theme: { schema: z.enum(["light", "dark"]), default: "light" },
});

const storage = createLocalStorage({ schema });

function ThemeToggle() {
  const [theme, writer] = useStorageValue(storage, "theme");

  return <button onClick={() => writer.set(theme === "dark" ? "light" : "dark")}>{theme}</button>;
}

The value is read during the render, with no promise and no loading state. Writing through the writer re-renders every component reading that key, and nothing else.

API Usage

useStorageValue(storage, key)

The key's value and its writer, read during the render.

Parameters:

  • storage: SyncPlatformStorage — a storage whose backend answers immediately. A storage that cannot is a compile error rather than a runtime one.
  • key: KeyOf<Definition> — one of the keys the schema declares.

Returns: [value, writer], where value is typed from the key — with undefined dropped when the key declares a default — and writer is { set, remove }.

const [theme, writer] = useStorageValue(storage, "theme"); // "light" | "dark"

useStorageWriter(storage, key)

The writer alone, for a component that writes a key it never displays and should not re-render when it changes.

Parameters: as above.

Returns: SyncStorageWriter<Definition[Key]>:

  • set(value): void — validates and writes, then tells every hook reading that key. Throws StorageValidationError and announces nothing if the schema refuses the value.
  • remove(): void — deletes the stored value, so the key reads as its default again. A different operation from writing undefined, which a key with a declared default cannot express at all.

A writer takes no options of its own, because a write has none to take. The invalid-data policy is a rule for reads, and it stays where it is declared.

useAsyncStorageValue(storage, key)

The same pair over any storage, including one that only answers later.

Parameters:

  • storage: PlatformStorage — any storage, synchronous backend or not.
  • key: KeyOf<Definition>

Returns: [result, writer], where result is a discriminated union on status:

| status | value | error | | ----------- | --------------- | ---------------------- | | "loading" | undefined | undefined | | "ready" | the key's value | undefined | | "failed" | undefined | PlatformStorageError |

Branch on status, never on the value. { status: "ready", value: undefined } means the key genuinely holds nothing, and a check like if (!result.value) would report that as still loading — collapsing the one distinction this library exists to keep. STORED_VALUE_STATUS carries the three names for anyone who prefers a constant to a string.

A write does not send the result back to "loading": the value already on screen stays until the next read lands, so nothing flashes.

useAsyncStorageWriter(storage, key)

The asynchronous writer alone.

Returns: AsyncStorageWriter<Definition[Key]> — set(value): Promise<void> and remove(): Promise<void>. Each promise settles exactly as the storage's own does, so a refused write is a rejection rather than a silent success, and nothing is announced when one fails.

createStorageHooks(storage)

The hooks bound to one storage, so it is not repeated at every call site and each key keeps its own type.

Parameters: storage: PlatformStorage | SyncPlatformStorage

Returns: { useValue, useWriter, notifyChanged }. A storage that answers immediately yields the synchronous hooks; anything else yields the asynchronous ones, so the same application code works on every platform.

Declare it beside the schema rather than inside a component. It builds hooks; it is not one.

export const { useValue, useWriter, notifyChanged } = createStorageHooks(storage);

const [theme, writer] = useValue("theme");

useHydrated()

Returns: boolean — false through a server render and the browser's first pass, true from the moment hydration ends. For rendering something browser-only without causing a mismatch.

useStorageErrors()

Returns: ReadonlyArray<StorageErrorEntry>, newest first. Each entry is { id, error, at, count }, carrying the error itself rather than a copy of its fields, so isStorageValidationError and the rest still apply. Identical repeats collapse into count. Empty on a server.

recordStorageError(error) and clearStorageErrors()

recordStorageError is what you pass as a storage's onError; clearStorageErrors empties the log, for a control that dismisses what has been read.

notifyStorageChanged(storage, key?) and subscribeToStorage(storage, listener, key?)

For a change the writers did not make, and for following a storage rather than one key.

Parameters: naming no key means the whole storage, which is what clearing it is.

Returns: subscribeToStorage returns an unsubscribe function.

readDeclaredValue(storage, key)

From the @platform-storage/react/server subpath. What the key reads as with nothing stored, which is what a server render answers with. The only export that carries no "use client", so a Server Component can call it without pulling the hooks into the server bundle.

Examples

A storage that only answers later

An extension storage area and AsyncStorage have no synchronous half at all, so a read has to say where it has got to.

import { useAsyncStorageValue } from "@platform-storage/react";

function Theme() {
  const [result, writer] = useAsyncStorageValue(storage, "theme");

  if (result.status === "loading") return <Spinner />;
  if (result.status === "failed") return <Problem error={result.error} />;

  return <button onClick={() => void writer.set("dark")}>{result.value}</button>;
}

Showing what went wrong

A value that no longer matches its schema does not throw the read. It falls back to the default, and the reason goes to the storage's onError observer, so a fallback is quiet unless something is listening.

import { recordStorageError, useStorageErrors } from "@platform-storage/react";

const storage = createLocalStorage({ schema, onError: recordStorageError });

function Failures() {
  const errors = useStorageErrors();

  return (
    <ul>
      {errors.map((entry) => (
        <li key={entry.id}>
          {entry.error.code} · {entry.error.message}
        </li>
      ))}
    </ul>
  );
}

Choosing what a bad value does

The hooks take no policy of their own. Declare it where it already belongs, on the key or on the storage, and every read through a hook obeys it:

const storage = createLocalStorage({ schema, onInvalid: "throw", onError: recordStorageError });

Two policies over one schema means two storages over one schema, which is a line of code and costs nothing.

A per-read policy is deliberately absent rather than forgotten. An options object written at a call site is a new object on every render, and a callback has no identity that can be compared at all, so neither could take part in the cache that keeps a snapshot still. ROADMAP.md records what an answer would have to look like.

[!IMPORTANT] onInvalid: "throw" and a hook combine into something worth knowing: the read happens during the render, so the error reaches the nearest error boundary rather than a try/catch. That is what you want in development. In production "fallback" with useStorageErrors() usually serves better, because the default is on screen and the failure is still visible.

Announcing a change the writers did not make

The writers announce their own writes. Two things happen behind them: clearing a storage, and writing straight to the backend past the library.

import { notifyStorageChanged } from "@platform-storage/react";

storage.clearSync();
notifyStorageChanged(storage);

subscribeToStorage(storage, listener, key?) is the other half, for anything that has to follow a storage as a whole rather than one key.

Cross-tab and cross-context changes are a separate matter: nothing reports them yet, on any platform. ROADMAP.md tracks the change subscription that will, and these two functions are the seam it slots into.

Server rendering

A server has no storage to read, so a component rendered there answers with what the schema alone declares. React is handed that same value for its first pass in the browser, which is what keeps the two in agreement, and swaps in the stored value the moment hydration ends.

import { readDeclaredValue } from "@platform-storage/react/server";

export default function Page() {
  const theme = readDeclaredValue(storage, "theme"); // "light", whatever a browser holds

  return <Shell initialTheme={theme} />;
}

[!NOTE] A synchronous read does not abolish the flash under server rendering, and nothing can: no server knows what a particular browser stored. What it removes is the promise, the effect and the loading state, not the repaint.

useHydrated() is the escape hatch for anything that must not render until the browser has taken over.

Where it runs

Every hook here works wherever React does, including React Native. What differs is the storage behind it:

  • Web storage answers immediately, so useStorageValue and useStorageWriter read and write during the render.
  • Extension storage areas and AsyncStorage only answer later. Those storages have no getSync at all, and the synchronous hooks refuse them at compile time rather than failing at runtime.

License

MIT © SkorpionG