@audemodo/responsive-keepalive
v0.1.0
Published
Render genuinely different component trees per breakpoint without losing state — React 19.2 Activity keep-alive, with anti-thrash and IME-safe switching.
Maintainers
Readme
responsive-keepalive
Render genuinely different component trees per breakpoint — without losing state.
Powered by React 19.2's <Activity>, with anti-thrash and IME-safe switching.
When your mobile and desktop views are different component trees, switching between
them at a breakpoint normally unmounts one and mounts the other — wiping scroll position,
form input, open menus, and any local state. responsive-keepalive keeps the inactive
tree mounted but hidden via React's <Activity>, so state survives every switch.
// The same counter's value survives switching mobile ⇄ desktop.
<Responsive
variant={variant}
variants={{
mobile: () => <MobileLayout />,
desktop: () => <DesktopLayout />,
}}
/>Features
- 🌳 Structural responsiveness — swap whole trees per breakpoint, not just CSS.
- 💾 State preserved across switches (scroll, input, selection) — keep-alive by default.
- 📐 Viewport, container (ResizeObserver), and value-level responsiveness.
- 🧩 A typed factory (
createResponsive) — one config, inferred keys, composable gates. - 🔁 Shared state across layouts without lifting (
useSharedState). - 🖥️ SSR-safe (
useSyncExternalStore) with anssrfallback variant. - ⌨️ IME-safe switching (
deferWhileComposing) and anti-thrash (settleMs). - 🪶 Zero runtime deps, tree-shakeable, dual ESM/CJS + types.
- 🛟 Graceful fallback to
swapon React < 19.2.
Install
npm i @audemodo/responsive-keepaliveRequires React 19.2+ (peer dependency) — <Activity> is a React 19.2 feature.
Quick start
Define your breakpoints once with the factory:
// responsive.ts
import { createResponsive } from '@audemodo/responsive-keepalive';
export const { Responsive, Provider } = createResponsive(
{ mobile: 0, desktop: 768 }, // integer px breakpoints
{ ssr: 'mobile' },
);Then render one prop per breakpoint — the active view is resolved from the viewport:
// App.tsx
import { Provider, Responsive } from './responsive';
export function App() {
return (
<Provider>
<Responsive mobile={() => <MobileLayout />} desktop={() => <DesktopLayout />} />
</Provider>
);
}Resize across 768px: the layout switches, but each layout keeps its state.
Core concepts
| Term | Meaning |
| ------------ | -------------------------------------------------------------------------------- |
| variant | The active breakpoint key, e.g. 'mobile' \| 'desktop'. |
| variants | A map of key → the tree (or () => tree) to render. |
| strategy | keepAlive (default, state preserved via Activity) or swap (unmount → reset). |
| mount | lazy (default, mount on first activation) or eager (mount all upfront). |
| ssr | The variant rendered on the server and before hydration. |
API
createResponsive(breakpoints, defaults?)
Builds a self-resolving <Responsive>, a root Provider, composable Match gates, and
pre-bound hooks — all typed to your config. Breakpoints are integer px min-widths.
export const { Responsive, Provider, Match, useVariant, useResponsiveValue } = createResponsive(
{ mobile: 0, desktop: 768 },
{ ssr: 'mobile' },
);
export const { Mobile, Desktop } = Match;// Composable gates — drop them anywhere under <Provider>.
<Provider>
<Desktop>
<DesktopNav />
</Desktop>
<Mobile>
<MobileTabBar />
</Mobile>
</Provider>;
// Pre-bound hooks — no queries to repeat.
function Toolbar() {
const variant = useVariant(); // 'mobile' | 'desktop'
const gap = useResponsiveValue({ mobile: 8, desktop: 24 });
return <div style={{ gap }}>…</div>;
}Returns { Responsive, Provider, Match, useVariant, useResponsiveValue, breakpoints }.
<Responsive>
The controlled primitive — you supply the active variant and a variants map.
import { Responsive, useMediaVariant } from '@audemodo/responsive-keepalive';
const variant = useMediaVariant({ mobile: 0, desktop: 768 }, { ssr: 'mobile' });
<Responsive
variant={variant}
variants={{
mobile: () => <MobileLayout />,
desktop: () => <DesktopLayout />,
}}
strategy="keepAlive" // default
mount="lazy" // default
/>;| Prop | Type | Default | Description |
| ---------- | ----------------------------------------- | ------------- | ---------------------------------------------- |
| variant | K | — | The active variant key (you control this). |
| variants | Record<K, ReactNode \| () => ReactNode> | — | Map of key → content. () => defers creation. |
| strategy | 'keepAlive' \| 'swap' | 'keepAlive' | Preserve inactive state, or unmount it. |
| mount | 'lazy' \| 'eager' | 'lazy' | Mount on first activation, or all upfront. |
useMediaVariant(breakpoints, options?)
Resolves a variant key from the viewport. Accepts integer px breakpoints (recommended) or raw media-query strings (for non-width features like orientation).
const variant = useMediaVariant(
{ mobile: 0, desktop: 768 },
{ ssr: 'mobile', settleMs: 150, deferWhileComposing: true },
);
// raw queries for non-width features
const scheme = useMediaVariant({
light: '(prefers-color-scheme: light)',
dark: '(prefers-color-scheme: dark)',
});useResponsiveValue(breakpoints, values, options?)
Picks a plain value per breakpoint — for column counts, gaps, or copy where a whole tree is overkill.
const columns = useResponsiveValue(
{ mobile: 0, tablet: 600, desktop: 1024 },
{ mobile: 1, tablet: 2, desktop: 3 },
{ ssr: 'mobile' },
);useContainerVariant(ref, breakpoints, options?)
Like useMediaVariant, but resolves from the element's own width (via ResizeObserver)
— so the same card can be a row in a wide column and a stack in a narrow one. Breakpoints
are integer min content-widths.
const ref = useRef<HTMLDivElement>(null);
const variant = useContainerVariant(ref, { stack: 0, row: 420, wide: 680 }, { ssr: 'stack' });
return <div ref={ref}>{/* branch on variant */}</div>;useSharedState(key, initialValue) & <SharedStateScope>
Share state by key across sibling layouts without lifting it to a parent. Because the
store lives in a scope ancestor, the value also survives swap and remounts.
function SearchField() {
const [query, setQuery] = useSharedState('search', '');
return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}
<Responsive>and the factory<Provider>mount a scope automatically, so inside them you can calluseSharedStatedirectly. Use<SharedStateScope>only for siblings that are under neither.
Options
All resolution hooks accept:
| Option | Type | Description |
| --------------------- | --------- | -------------------------------------------------------------- |
| ssr | K | Variant returned on the server and before hydration. |
| settleMs | number | Anti-thrash: commit a change only after it is stable for N ms. |
| deferWhileComposing | boolean | Hold switches during IME composition (media hooks only). |
Exported types
Strategy, Mount, VariantMap, ResponsiveProps, MediaInput, MediaVariantOptions,
ResponsiveValueOptions, ContainerVariantOptions, BreakpointConfig,
CreateResponsiveOptions, ConfiguredResponsive, ConfiguredResponsiveProps,
ResponsiveProviderProps, MatchProps, SetSharedState.
Recipes
Next.js / SSR — feed a request-derived variant so the server renders the right tree:
// app/layout.tsx (server)
<Provider ssr={isMobileUA(headers()) ? 'mobile' : 'desktop'}>{children}</Provider>Shared search across layouts — the mobile and desktop search boxes stay in sync:
<Provider>
<Mobile>
<SearchField />
</Mobile>
<Desktop>
<SearchField />
</Desktop>
</Provider>Behavior notes
- SSR renders the
ssrvariant on the server; after hydration it resolves to the real viewport, which may cause a one-time layout shift (not continuous flicker). - React < 19.2 has no
<Activity>, so the library falls back toswap(state is not preserved) and warns once in development. strategyandmountare independent:mount="eager"only matters underkeepAlive.
License
MIT © vi-wolhwa
