React Router Integration

@guanriyue/decurl connects Decurl Search Fields to React Router. Its goal is to let URL search be read and updated like ordinary React state, while keeping the URL as the final source of truth.

hooks

The minimal usage is to import hooks and Search Fields tools directly:

import { useSearchValue } from '@guanriyue/decurl';
import { defineFields, field } from '@guanriyue/decurl/codec';
import { pipe, shape, trim } from '@guanriyue/decurl/decode';

const fields = defineFields({
  keyword: field({
    name: 'q',
    decode: pipe(trim, shape(/.+/)),
  }),
});

const SearchInput = () => {
  const [keyword, setKeyword] = useSearchValue(fields.keyword);

  return (
    <input
      value={keyword ?? ''}
      onChange={(event) => {
        const value = event.currentTarget.value.trim();
        setKeyword(value === '' ? undefined : value);
      }}
    />
  );
};

This style fits applications with one default search store.

Default hooks do not require you to manually create a Provider. useSearchValue and useSearchValues use the default search store and read the current React Router location / navigate inside the hook.

Single Value

Use useSearchValue when you only care about one field. The demo below uses an input as a common scenario: the input value is synchronized to the demo_q param in the current page URL.

Current value
Current search params(empty)
import { useSearchValue } from '@guanriyue/decurl';
import { defineFields } from '@guanriyue/decurl/codec';
import { trim } from '@guanriyue/decurl/decode';
import { useLocation } from 'react-router';
import { toSearchText, useDemoI18n } from '@/examples/i18n';

const fields = defineFields({
  keyword: {
    name: 'demo_q',
    decode: trim,
  },
});

const QueryInputDemo = () => {
  const t = useDemoI18n();
  const location = useLocation();
  const [keyword, setKeyword] = useSearchValue(fields.keyword);

  return (
    <div className="decurl-demo">
      <label className="decurl-demo__field">
        <span>{t('demo.keyword')}</span>
        <input
          value={keyword ?? ''}
          placeholder={t('demo.queryInput.placeholder')}
          onChange={(event) => {
            const nextValue = event.currentTarget.value.trim();
            setKeyword(nextValue === '' ? undefined : nextValue);
          }}
        />
      </label>
      <div className="decurl-demo__state">
        <span>{t('demo.currentValue')}</span>
        <code>{keyword}</code>
      </div>
      <div className="decurl-demo__state">
        <span>{t('demo.currentSearch')}</span>
        <code>{toSearchText(location.search, t)}</code>
      </div>
    </div>
  );
};

export default QueryInputDemo;

Multiple Values

Use useSearchValues when you need to read and update multiple fields at the same time. The demo below uses a query form as a common scenario: when URL values change, the demo syncs them to form elements; on submit, it reads raw input with FormData and patches the result to the URL.

Current search params(empty)
import { useSearchValues } from '@guanriyue/decurl';
import { defineFields, field } from '@guanriyue/decurl/codec';
import {
  elementOf,
  min,
  pipe,
  shape,
  toBoolean,
  toNumber,
  trim,
} from '@guanriyue/decurl/decode';
import { useEffect, useRef } from 'react';
import { useLocation } from 'react-router';
import { toSearchText, useDemoI18n } from '@/examples/i18n';

const fields = defineFields({
  query: field({
    name: 'demo_query',
    decode: trim,
  }),
  category: field({
    name: 'demo_category',
    decode: elementOf(['all', 'docs', 'api']),
    defaultValue: 'all',
  }),
  inStock: field({
    name: 'demo_in_stock',
    decode: pipe(shape.boolish, toBoolean),
    defaultValue: false,
  }),
  page: field({
    name: 'demo_page',
    decode: pipe(shape.integer, toNumber, min(1)),
    defaultValue: 1,
  }),
});

const setFieldValue = (
  form: HTMLFormElement,
  name: string,
  value: string,
): void => {
  const element = form.elements.namedItem(name);

  if (
    element instanceof HTMLInputElement ||
    element instanceof HTMLSelectElement
  ) {
    element.value = value;
  }
};

const setCheckboxValue = (
  form: HTMLFormElement,
  name: string,
  checked: boolean,
): void => {
  const element = form.elements.namedItem(name);

  if (element instanceof HTMLInputElement) {
    element.checked = checked;
  }
};

const normalizeCategory = (value: FormDataEntryValue | undefined) => {
  return value === 'docs' || value === 'api' ? value : 'all';
};

const QueryFormDemo = () => {
  const t = useDemoI18n();
  const location = useLocation();
  const formRef = useRef<HTMLFormElement>(null);
  const [values, setValues] = useSearchValues(fields);

  useEffect(() => {
    const form = formRef.current;

    if (!form) {
      return;
    }

    setFieldValue(form, 'query', values.query ?? '');
    setFieldValue(form, 'category', values.category);
    setCheckboxValue(form, 'inStock', values.inStock);
  }, [values]);

  return (
    <form
      ref={formRef}
      className="decurl-demo"
      onSubmit={(event) => {
        event.preventDefault();

        const formData = new FormData(event.currentTarget);
        const rawValues = Object.fromEntries(formData);
        const nextQuery =
          typeof rawValues.query === 'string' ? rawValues.query.trim() : '';

        setValues({
          query: nextQuery === '' ? undefined : nextQuery,
          category: normalizeCategory(rawValues.category),
          inStock: rawValues.inStock === 'true',
          page: 1,
        });
      }}
    >
      <div className="decurl-demo__grid">
        <label className="decurl-demo__field">
          <span>{t('demo.keyword')}</span>
          <input name="query" placeholder={t('demo.queryForm.placeholder')} />
        </label>
        <label className="decurl-demo__field">
          <span>{t('demo.queryForm.category')}</span>
          <select name="category">
            <option value="all">{t('demo.queryForm.all')}</option>
            <option value="docs">{t('demo.queryForm.guides')}</option>
            <option value="api">{t('demo.queryForm.api')}</option>
          </select>
        </label>
      </div>
      <label className="decurl-demo__check">
        <input name="inStock" type="checkbox" value="true" />
        <span>{t('demo.queryForm.availableOnly')}</span>
      </label>
      <div className="decurl-demo__actions">
        <button type="submit">{t('demo.queryForm.submit')}</button>
        <button
          type="button"
          className="decurl-demo__button-secondary"
          onClick={() => {
            setValues({
              query: undefined,
              category: 'all',
              inStock: false,
              page: 1,
            });
          }}
        >
          {t('demo.reset')}
        </button>
      </div>
      <div className="decurl-demo__state">
        <span>{t('demo.currentSearch')}</span>
        <code>{toSearchText(location.search, t)}</code>
      </div>
    </form>
  );
};

export default QueryFormDemo;

Shared URL Key

Different hooks can listen to the same URL key. As long as the field codecs map to the same name, they read values from the same URL search state.

Value read by useSearchValue(empty)
Value read by useSearchValues(empty)
Current search params(empty)
import { useSearchValue, useSearchValues } from '@guanriyue/decurl';
import { defineFields, field } from '@guanriyue/decurl/codec';
import { trim } from '@guanriyue/decurl/decode';
import { useLocation } from 'react-router';
import { toSearchText, useDemoI18n } from '@/examples/i18n';

const singleField = defineFields({
  keyword: field({
    name: 'demo_shared',
    decode: trim,
  }),
});

const valuesFields = defineFields({
  query: field({
    name: 'demo_shared',
    decode: trim,
  }),
});

const SharedKeyHooksDemo = () => {
  const t = useDemoI18n();
  const location = useLocation();
  const [keyword, setKeyword] = useSearchValue(singleField.keyword);
  const [values, setValues] = useSearchValues(valuesFields);

  return (
    <div className="decurl-demo">
      <div className="decurl-demo__grid">
        <label className="decurl-demo__field">
          <span>useSearchValue</span>
          <input
            value={keyword ?? ''}
            placeholder={t('demo.shared.singlePlaceholder')}
            onChange={(event) => {
              const nextValue = event.currentTarget.value.trim();
              setKeyword(nextValue === '' ? undefined : nextValue);
            }}
          />
        </label>
        <label className="decurl-demo__field">
          <span>useSearchValues</span>
          <input
            value={values.query ?? ''}
            placeholder={t('demo.shared.valuesPlaceholder')}
            onChange={(event) => {
              const nextValue = event.currentTarget.value.trim();
              setValues({
                query: nextValue === '' ? undefined : nextValue,
              });
            }}
          />
        </label>
        <div className="decurl-demo__state">
          <span>{t('demo.shared.singleValue')}</span>
          <code>{keyword ?? t('demo.empty')}</code>
        </div>
        <div className="decurl-demo__state">
          <span>{t('demo.shared.valuesValue')}</span>
          <code>{values.query ?? t('demo.empty')}</code>
        </div>
      </div>
      <div className="decurl-demo__state">
        <span>{t('demo.currentSearch')}</span>
        <code>{toSearchText(location.search, t)}</code>
      </div>
    </div>
  );
};

export default SharedKeyHooksDemo;

Equality and Stable References

Hooks compare previous and next values after decode. When the values are equal, the hook reuses the previous value reference.

Stable references fit React's optimization model better: when a hook return value is used as useMemo or useEffect dependencies, or passed as props to a memoized component, unchanged search values will not cause extra dependency changes.

Default comparison rules come from the field codec:

  • Single fields use Object.is by default.
  • Multi fields use order-sensitive shallow array comparison by default.
  • If the field codec provides a custom eq, Decurl uses that comparison.

The demo below shows the difference: changing the watched key changes the values reference; changing an unrelated key may still re-render the component because React Router location changed, but the values reference returned by useSearchValues stays stable.

Component renders1
Values reference changes1
Current search params(empty)
import { useSearchValue, useSearchValues } from '@guanriyue/decurl';
import { defineFields, field } from '@guanriyue/decurl/codec';
import { trim } from '@guanriyue/decurl/decode';
import { useRef } from 'react';
import { useLocation } from 'react-router';
import { toSearchText, useDemoI18n } from '@/examples/i18n';

const watchedFields = defineFields({
  keyword: field({
    name: 'demo_equal_q',
    decode: trim,
    defaultValue: '',
  }),
});

const unrelatedFields = defineFields({
  value: field({
    name: 'demo_equal_other',
    decode: trim,
    defaultValue: '',
  }),
});

const EqualityStableReferenceDemo = () => {
  const t = useDemoI18n();
  const location = useLocation();
  const [values, setValues] = useSearchValues(watchedFields);
  const [unrelatedValue, setUnrelatedValue] = useSearchValue(
    unrelatedFields.value,
  );
  const renderCountRef = useRef(0);
  const valuesChangeCountRef = useRef(0);
  const previousValuesRef = useRef<typeof values | undefined>(undefined);

  renderCountRef.current += 1;

  if (previousValuesRef.current !== values) {
    valuesChangeCountRef.current += 1;
    previousValuesRef.current = values;
  }

  return (
    <div className="decurl-demo">
      <div className="decurl-demo__grid">
        <label className="decurl-demo__field">
          <span>{t('demo.equality.watchedKey')}</span>
          <input
            value={values.keyword}
            placeholder={t('demo.equality.watchedPlaceholder')}
            onChange={(event) => {
              setValues({ keyword: event.currentTarget.value });
            }}
          />
        </label>
        <label className="decurl-demo__field">
          <span>{t('demo.equality.unrelatedKey')}</span>
          <input
            value={unrelatedValue}
            placeholder={t('demo.equality.unrelatedPlaceholder')}
            onChange={(event) => {
              setUnrelatedValue(event.currentTarget.value);
            }}
          />
        </label>
      </div>
      <div className="decurl-demo__grid">
        <div className="decurl-demo__state">
          <span>{t('demo.equality.renderCount')}</span>
          <code>{renderCountRef.current}</code>
        </div>
        <div className="decurl-demo__state">
          <span>{t('demo.equality.valuesChangeCount')}</span>
          <code>{valuesChangeCountRef.current}</code>
        </div>
      </div>
      <div className="decurl-demo__state">
        <span>{t('demo.currentSearch')}</span>
        <code>{toSearchText(location.search, t)}</code>
      </div>
    </div>
  );
};

export default EqualityStableReferenceDemo;

App Integration

import { BrowserRouter } from 'react-router';

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

As long as the component is inside a React Router environment, it can use the default hooks directly.

If you need to configure store behavior, provide an isolated store, or centralize React Router location access in a separate component, see Provided Runtime.

Relationship with the codec Entry

React Router hooks do not redefine URL fields. Search Fields are defined with @guanriyue/decurl/codec and then reused by hooks:

import { defineFields, field } from '@guanriyue/decurl/codec';

const searchFields = defineFields({
  page: field({
    decode: decodePage,
    defaultValue: 1,
  }),
});

The same Search Fields can be used for:

  • URLSearchParams decode/encode.
  • React Router hooks.
  • Tests and fixtures.

Anti-pattern: Same Key, Different Rules

If the same key uses different defaultValue values, different hooks may read different results when the key is missing from the URL. This is not recommended on the same page. Try to keep the same field codec rules for the same search param key.

useSearchValue defaultValueleft-default
useSearchValues defaultValueright-default
Current search params(empty)
import { useSearchValue, useSearchValues } from '@guanriyue/decurl';
import { defineFields, field } from '@guanriyue/decurl/codec';
import { trim } from '@guanriyue/decurl/decode';
import { useLocation } from 'react-router';
import { toSearchText, useDemoI18n } from '@/examples/i18n';

const leftField = defineFields({
  value: field({
    name: 'demo_shared_default',
    decode: trim,
    defaultValue: 'left-default',
  }),
});

const rightFields = defineFields({
  value: field({
    name: 'demo_shared_default',
    decode: trim,
    defaultValue: 'right-default',
  }),
});

const SharedKeyDefaultsDemo = () => {
  const t = useDemoI18n();
  const location = useLocation();
  const [leftValue, setLeftValue] = useSearchValue(leftField.value);
  const [rightValues, setRightValues] = useSearchValues(rightFields);

  return (
    <div className="decurl-demo">
      <div className="decurl-demo__grid">
        <div className="decurl-demo__state">
          <span>useSearchValue defaultValue</span>
          <code>{leftValue}</code>
        </div>
        <div className="decurl-demo__state">
          <span>useSearchValues defaultValue</span>
          <code>{rightValues.value}</code>
        </div>
      </div>
      <div className="decurl-demo__actions">
        <button
          type="button"
          onClick={() => {
            setLeftValue('from-left');
          }}
        >
          {t('demo.sharedDefaults.writeLeft')}
        </button>
        <button
          type="button"
          onClick={() => {
            setRightValues({ value: 'from-right' });
          }}
        >
          {t('demo.sharedDefaults.writeRight')}
        </button>
        <button
          type="button"
          className="decurl-demo__button-secondary"
          onClick={() => {
            setLeftValue(undefined);
          }}
        >
          {t('demo.sharedDefaults.deleteKey')}
        </button>
      </div>
      <div className="decurl-demo__state">
        <span>{t('demo.currentSearch')}</span>
        <code>{toSearchText(location.search, t)}</code>
      </div>
    </div>
  );
};

export default SharedKeyDefaultsDemo;