Provided Runtime

The default useSearchValue and useSearchValues hooks can be used directly. They read the nearest SearchProvider store; without a SearchProvider, they use the default global store.

Default hooks also read React Router location and navigate inside the hook consumer. This zero-config behavior fits most pages, but it also means components that consume search state directly subscribe to React Router location.

@guanriyue/decurl/provided is an optional optimization entry. It separates store provision from React Router capability access:

  • SearchProvider provides the store and configures store behavior such as flushDelay and flushMode.
  • SearchRuntimeConnector reads React Router location, navigate, or a router instance, then gives those capabilities to the current store.
  • useProvidedSearchValue and useProvidedSearchValues only read the nearest SearchProvider store. They do not read React Router runtime by themselves.

Basic Usage

SearchProvider does not need to be inside the Router. When using component routers, SearchRuntimeConnector must be inside the Router and render before any provided-hook consumer:

import { SearchProvider } from '@guanriyue/decurl';
import {
  SearchRuntimeConnector,
  useProvidedSearchValues,
} from '@guanriyue/decurl/provided';
import { BrowserRouter } from 'react-router';

export const App = () => {
  return (
    <SearchProvider>
      <BrowserRouter>
        <SearchRuntimeConnector />
        <SearchPanel />
      </BrowserRouter>
    </SearchProvider>
  );
};

const SearchPanel = () => {
  const [values, setValues] = useProvidedSearchValues(searchFields);

  return null;
};

If SearchProvider is missing, provided hooks throw immediately. If SearchRuntimeConnector is missing, the store has not received React Router location and navigate capabilities, so reading search state also fails.

Store Configuration

SearchProvider creates and provides the store. Pass flush options to SearchProvider when you need to configure the flush strategy:

<SearchProvider flushDelay={80} flushMode="debounce">
  <BrowserRouter>
    <SearchRuntimeConnector />
    <SearchPanel />
  </BrowserRouter>
</SearchProvider>

Business code usually does not need a long flushDelay. It is mainly useful for reducing router navigation frequency caused by high-frequency interactions such as inputs and filters.

Start from tens of milliseconds and adjust by interaction type. Input interactions often fit debounce; click interactions usually work with the default strategy or a shorter delay.

Data Router

When using RouterProvider / Data Router, pass the router instance to SearchRuntimeConnector:

import { SearchProvider } from '@guanriyue/decurl';
import { SearchRuntimeConnector } from '@guanriyue/decurl/provided';
import { RouterProvider, createBrowserRouter } from 'react-router';

const router = createBrowserRouter(routes);

export const App = () => {
  return (
    <SearchProvider>
      <SearchRuntimeConnector router={router} />
      <RouterProvider router={router} />
    </SearchProvider>
  );
};

This form uses the router instance's state.location, navigate, and subscribe to keep the store aligned with React Router.

Long Delay Observation Demo

The demo below intentionally uses a long flushDelay. It is not a recommended business configuration; it only makes two phases easier to observe:

  • After clicking, consumers that listen to the corresponding key respond immediately.
  • After URL confirmation, the parent route tree may re-render. Non-memoized child components are rendered with the parent, while memoized children can block this propagation.
parent / React Router location(empty)parent renders: 1observation flushDelay: 2000ms
plain countAvalue: 0renders: 1
plain countBvalue: 0renders: 1
memo countAvalue: 0renders: 1
memo countBvalue: 0renders: 1
import { SearchProvider } from '@guanriyue/decurl';
import {
  SearchRuntimeConnector,
  useProvidedSearchValue,
} from '@guanriyue/decurl/provided';
import { defineFields, field } from '@guanriyue/decurl/codec';
import { min, pipe, shape, toNumber } from '@guanriyue/decurl/decode';
import { memo, useRef } from 'react';
import { useLocation } from 'react-router';
import { toSearchText, useDemoI18n } from '@/examples/i18n';

const fields = defineFields({
  countA: field({
    name: 'demo_delay_a',
    decode: pipe(shape.integer, toNumber, min(0)),
    defaultValue: 0,
  }),
  countB: field({
    name: 'demo_delay_b',
    decode: pipe(shape.integer, toNumber, min(0)),
    defaultValue: 0,
  }),
});

const observationDelay = 2000;

const ProvidedRuntimeDelayDemo = () => {
  return (
    <SearchProvider flushDelay={observationDelay} flushMode="debounce">
      <SearchRuntimeConnector />
      <DemoShell />
    </SearchProvider>
  );
};

const DemoShell = () => {
  const t = useDemoI18n();
  const renderCountRef = useRef(0);
  const location = useLocation();

  renderCountRef.current += 1;

  return (
    <div className="decurl-demo">
      <div className="decurl-demo__state">
        <span>parent / React Router location</span>
        <code>{toSearchText(location.search, t)}</code>
        <code>parent renders: {renderCountRef.current}</code>
        <code>observation flushDelay: {observationDelay}ms</code>
      </div>
      <div className="decurl-demo__grid">
        <CountPanel
          fieldKey="countA"
          title="plain countA"
          buttonText={t('demo.provided.updateA')}
        />
        <CountPanel
          fieldKey="countB"
          title="plain countB"
          buttonText={t('demo.provided.updateB')}
        />
      </div>
      <div className="decurl-demo__grid">
        <MemoCountPanel
          fieldKey="countA"
          title="memo countA"
          buttonText={t('demo.provided.updateA')}
        />
        <MemoCountPanel
          fieldKey="countB"
          title="memo countB"
          buttonText={t('demo.provided.updateB')}
        />
      </div>
    </div>
  );
};

type CountPanelProps = {
  fieldKey: 'countA' | 'countB';
  title: string;
  buttonText: string;
};

const CountPanel = ({ fieldKey, title, buttonText }: CountPanelProps) => {
  const renderCountRef = useRef(0);
  const [count, setCount] = useProvidedSearchValue(fields[fieldKey]);

  renderCountRef.current += 1;

  return (
    <section className="decurl-demo__state">
      <span>{title}</span>
      <code>value: {count}</code>
      <code>renders: {renderCountRef.current}</code>
      <div className="decurl-demo__actions">
        <button
          type="button"
          onClick={() => {
            setCount((value) => value + 1);
          }}
        >
          {buttonText}
        </button>
      </div>
    </section>
  );
};

const MemoCountPanel = memo(CountPanel);

export default ProvidedRuntimeDelayDemo;

Extra Render Source Demo

The demo below has two components listening to demo_bound_a and demo_bound_b.

When clicking "Update A", countA updates immediately through optimistic state. React Router location confirms the URL change only after the observation flushDelay. This demo highlights two sources of extra renders:

  • Optimistic updates triggered by the Decurl store itself.
  • Page tree updates caused by React Router after it observes a location change.
observation flushDelay2000ms
countAvalue: 0renders: 1
countBvalue: 0renders: 1
React Router location(empty)renders: 1
import { SearchProvider } from '@guanriyue/decurl';
import {
  SearchRuntimeConnector,
  useProvidedSearchValue,
} from '@guanriyue/decurl/provided';
import { defineFields, field } from '@guanriyue/decurl/codec';
import { min, pipe, shape, toNumber } from '@guanriyue/decurl/decode';
import { useRef } from 'react';
import { useLocation } from 'react-router';
import { toSearchText, useDemoI18n } from '@/examples/i18n';

const fields = defineFields({
  countA: field({
    name: 'demo_bound_a',
    decode: pipe(shape.integer, toNumber, min(0)),
    defaultValue: 0,
  }),
  countB: field({
    name: 'demo_bound_b',
    decode: pipe(shape.integer, toNumber, min(0)),
    defaultValue: 0,
  }),
});

const observationDelay = 2000;

const ProvidedRuntimeRendersDemo = () => {
  return (
    <SearchProvider flushDelay={observationDelay} flushMode="debounce">
      <SearchRuntimeConnector />
      <div className="decurl-demo">
        <div className="decurl-demo__state">
          <span>observation flushDelay</span>
          <code>{observationDelay}ms</code>
        </div>
        <div className="decurl-demo__grid">
          <CountA />
          <CountB />
        </div>
        <LocationSearch />
      </div>
    </SearchProvider>
  );
};

const LocationSearch = () => {
  const t = useDemoI18n();
  const renderCountRef = useRef(0);
  const location = useLocation();

  renderCountRef.current += 1;

  return (
    <div className="decurl-demo__state">
      <span>React Router location</span>
      <code>{toSearchText(location.search, t)}</code>
      <code>renders: {renderCountRef.current}</code>
    </div>
  );
};

const CountA = () => {
  const t = useDemoI18n();
  const renderCountRef = useRef(0);
  const [count, setCount] = useProvidedSearchValue(fields.countA);

  renderCountRef.current += 1;

  return (
    <section className="decurl-demo__state">
      <span>countA</span>
      <code>value: {count}</code>
      <code>renders: {renderCountRef.current}</code>
      <div className="decurl-demo__actions">
        <button
          type="button"
          onClick={() => {
            setCount((value) => value + 1);
          }}
        >
          {t('demo.provided.updateA')}
        </button>
      </div>
    </section>
  );
};

const CountB = () => {
  const t = useDemoI18n();
  const renderCountRef = useRef(0);
  const [count, setCount] = useProvidedSearchValue(fields.countB);

  renderCountRef.current += 1;

  return (
    <section className="decurl-demo__state">
      <span>countB</span>
      <code>value: {count}</code>
      <code>renders: {renderCountRef.current}</code>
      <div className="decurl-demo__actions">
        <button
          type="button"
          onClick={() => {
            setCount((value) => value + 1);
          }}
        >
          {t('demo.provided.updateB')}
        </button>
      </div>
    </section>
  );
};

export default ProvidedRuntimeRendersDemo;

The optimization shown by this demo is narrow:

  • provided hooks consume the SearchProvider store snapshot. Unlike default hooks, they do not call React Router useLocation inside the hook consumer.
  • When React Router confirms the location, provided hooks do not re-run merely because they subscribed to useLocation themselves.

This does not guarantee fewer renders. React Router may still re-render the route tree after location confirmation and propagate rendering to children. Without React.memo, useMemo, or similar optimizations, the actual effect may be small or invisible.

When To Use

Use this decision order:

  1. Start with the default useSearchValue / useSearchValues hooks.
  2. Add an optional SearchProvider when you need to configure or isolate store behavior.
  3. Switch to the provided entry only when the page is render-sensitive and you have confirmed that the default hooks' React Router location subscription adds measurable cost.

provided runtime only centralizes React Router capability access in SearchRuntimeConnector. It is not required for basic hooks and does not guarantee fewer renders for every page.