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.
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.
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.
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.
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 defaultValue left-default
useSearchValues defaultValue right-default
Write left Write right Delete key
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;