BackOpenStatus Logo

State Management

An adapter pattern that decouples filter state management from the table components. Use nuqs (URL-based), Zustand (client-side), memory (ephemeral), or build your own adapter.

Schema Definition

The schema is the single source of truth for filter field types, defaults, and serialization.

import { createSchema, field } from "@/lib/store/schema";
 
export const filterSchema = createSchema({
  // Checkbox filters (arrays of values)
  level: field.array(field.stringLiteral(["success", "warning", "error"])),
  status: field.array(field.number()).delimiter(","),
  regions: field.array(field.stringLiteral(["ams", "gru", "syd"])),
 
  // Text input filters
  host: field.string(),
 
  // Slider filters (ranges)
  latency: field.array(field.number()).delimiter("-"),
 
  // Date range filter
  date: field.array(field.timestamp()).delimiter("-"),
 
  // Sorting
  sort: field.sort(),
 
  // UI state
  live: field.boolean().default(false),
  size: field.number().default(40),
});
 
export type FilterState = typeof filterSchema._type;

Field Types

// Primitives (default to null)
field.string()                              // string | null
field.number()                              // number | null (parseInt)
field.boolean()                             // boolean | null
field.timestamp()                           // Date | null (serialized as ms)
 
// Constrained
field.stringLiteral(["a", "b", "c"])        // 'a' | 'b' | 'c' | null
 
// Sorting
field.sort()                                // { id: string; desc: boolean } | null
                                            // Serialized as "columnId.asc" / "columnId.desc"
 
// Arrays
field.array(field.string())                 // string[]
field.array(field.number())                 // number[]
field.array(field.stringLiteral([...]))     // Literal[]
 
// Modifiers (chainable)
field.array(field.string())
  .default(["default"])                     // Default value on reset
  .delimiter(",")                           // URL serialization separator

Adapters

Goal is to BYOS (Bring Your Own Store) - so if you want to use a specific state management, you can. Here are some adapters we've implemented for you.

nuqs Adapter (URL-based)

Syncs filter state to URL search params. Enables shareable URLs, browser history, and server-side parsing. Best for most use cases.

OptionDefaultDescription
idrequiredTable identifier (namespaces URL params)
shallowtrueDon't trigger Next.js navigation on change
history"push""push" or "replace" for browser history
throttleMs50Throttle URL updates (ms)
scrollfalseScroll to top on filter change
import { useNuqsAdapter } from "@/lib/store/adapters/nuqs";
 
function Client() {
  const adapter = useNuqsAdapter(filterSchema.definition, {
    id: "my-table",
    shallow: true, // Don't trigger navigation
    history: "push", // "push" | "replace"
    throttleMs: 50, // Throttle URL updates
  });
 
  return (
    <DataTableStoreProvider adapter={adapter}>
      <DataTableContent />
    </DataTableStoreProvider>
  );
}

For server-side parsing:

import { createNuqsSearchParams } from "@/lib/store/adapters/nuqs/server";
 
export const { searchParamsParser, searchParamsCache, searchParamsSerializer } =
  createNuqsSearchParams(filterSchema.definition);
 
// In page.tsx
export default async function Page({ searchParams }) {
  const search = await searchParamsCache.parse(searchParams);
  // Use for server-side data fetching or prefetching...
}

Zustand Adapter (Client-side)

Integrates with existing Zustand stores. Use when you already have a Zustand store and want filter state to live alongside your app state, or when you don't want filters in the URL.

import {
  createFilterSlice,
  useZustandAdapter,
} from "@/lib/store/adapters/zustand";
import { create } from "zustand";
 
export const useFilterStore = create<Record<string, unknown>>((set, get) => ({
  ...createFilterSlice(filterSchema.definition, "my-table", set, get),
}));
 
function Client() {
  const adapter = useZustandAdapter(useFilterStore, filterSchema.definition, {
    id: "my-table",
  });
 
  return (
    <DataTableStoreProvider adapter={adapter}>
      <DataTableContent />
    </DataTableStoreProvider>
  );
}

Memory Adapter (Ephemeral)

Lightweight in-memory state. No URL sync, no external store. State resets on unmount. Useful for embedded tables, the builder, or preview contexts where you don't want to pollute the URL.

import { useMemoryAdapter } from "@/lib/store/adapters/memory";
 
const adapter = useMemoryAdapter(filterSchema.definition);

Custom Adapters

Implement the StoreAdapter interface:

interface StoreAdapter<T extends Record<string, unknown>> {
  subscribe(listener: () => void): () => void;
  getSnapshot(): { state: T; version: number };
  getServerSnapshot?(): { state: T; version: number };
  setState(partial: Partial<T>): void;
  setField<K extends keyof T>(key: K, value: T[K]): void;
  reset(fields?: (keyof T)[]): void;
  pause(): void;
  resume(): void;
  isPaused(): boolean;
  destroy(): void;
  getTableId(): string;
  getSchema(): SchemaDefinition;
  getDefaults(): T;
}

Provider & Hooks

DataTableStoreProvider

Wraps your components with the adapter context:

<DataTableStoreProvider adapter={adapter}>{children}</DataTableStoreProvider>

useFilterState

Read filter state. Uses useSyncExternalStore for optimal React 18+ compatibility.

// Read entire state
const state = useFilterState<FilterState>();
 
// Read with selector (only re-renders when selected value changes)
const live = useFilterState<FilterState, boolean>((s) => s.live);
const regions = useFilterState<FilterState, string[]>((s) => s.regions);

useFilterActions

Modify filter state.

const {
  setFilter, // Set a single field
  setFilters, // Set multiple fields at once
  resetFilter, // Reset a single field to default
  resetAllFilters, // Reset all filters
  pause, // Pause state updates (for live mode)
  resume, // Resume and apply queued changes
  isPaused, // Check if paused
} = useFilterActions<FilterState>();
 
setFilter("regions", ["ams", "gru"]);
setFilters({ regions: ["ams"], host: "api.example.com" });
resetFilter("regions");
resetAllFilters();

useReactTableSync

Bidirectional sync between adapter filter state and React Table's columnFilters. Useful when you need React Table's internal filtering to stay in sync with adapter state.

import { useReactTableSync } from "@/lib/store";
 
const table = useReactTable({ ... });
 
useReactTableSync({
  table,
  filterFields,
});

useFilterField

Work with a single field. Combines read and write.

const { value, setValue, reset } = useFilterField<FilterState, "regions">(
  "regions",
);
 
setValue(["ams", "gru", "fra"]);
reset(); // Back to default
GitHubXBluesky

Powered by OpenStatus