Bakgrunn

Jeg har de siste årene jobbet en del med ulike søkesider. Både forvaltning av sider andre har laget - og noen jeg selv har laget fra grunn. En typisk slik søkeside har et fritekstfelt samt en rekke filtre for å rafinere søket. Et eksempel er denne siden jeg har laget for å søke i et sakssystem:

Firefox med enkel HTML

Her søker vi etter saker med fritekst “test”, kunde “Test customer2”, prioriteter “high”/“critical” og så ekskluderer vi lukkede saker. Vi har mange flotte MUI5 felter og komponenter for å la brukeren sette opp søkekriteriene. Disse valgene som er gjort er selve tilstanden vi snakker om. Det hele skal ende opp som et JSON-kriterie som sendes til søke-APIet vårt:

{
  "keywords": ["test"],
  "customer": [4424],
  "priority": ["high", "critical"],
  "subCriteria": [
    {
      "status": ["closed"],
      "exclude": true
    }
  ]
}

Som vi ser er det litt forskjell på tilstand som vises i grensesnittet og sendes til API. Kunden vises med navn - men kun id sendes inn. Fritekst sendes inn som array - så ["en test"] og ["en", "test"] vil muligens bety to forskjellige ting. Og ekskludering av status ender som et underkriterie med “exclude” flagg satt. Allerede her aner vi noen valg man må ta rundt tilstand - skal vi lagre tilstand slik UI bruker den? Eller slik API forventer å få den? Eller kanskje begge deler for å være sikker?

En siste liten sak her er at vi ønsker at URL skal oppdateres med de søkevalg man har tatt. Slik at søket du har laget kan lagres som bokmerke i nettleser eller sendes som lenke til en kollega og sånt. URL for søket mitt over her ser slik ut:

<domene>/<applikasjon>/search?keywords=test&customer[]=4424&excludeStatus[]=closed&priority[]=high,critical

Alright! Dette er altså bakgrunnen for mitt søk. Oppsummert har vi følgende “krav”:

  • Ta vare på tilstand for inneværende søk
  • Tilstand kan oppdateres og vises i UI-komponenter
  • Tilstand kan oppdateres og vises via URL
  • Forskjellig form på tilstand for UI, URL og API

Ikke den værste kravspekken jeg har sett. La oss nå se litt videre på hvordan dette kan gjøres skikkelig komplisert og proppfullt av artige feil. Så vil min løsning som vises til slutt stilles i ekstra godt lys.

“Vanlig” løsning i React-verden

Den løsningen jeg har sett mest av rundt dette her er at man lager noe som holder på API-kriteriet som tilstand på toppen av komponenten. Typisk med noe Redux-lureri, global context eller hva som er populært i React-verden da koden ble skrevet. Prinsippielt spiller det liten rolle - et eller annet holder på kriteriet på høyt nivå og gir mulighet til å oppdatere. Med en useState hook vil det se ca slik ut - vi lager oss en hook for søkesiden som håndterer state og utfører søk:

const useSearchPage = () => {
  // Global state - kan være redux eller useState eller noe annet fancy..
  const [criteria, setCriteria] = useState({});
  // Util metode for å ta vare på inneværende tilstand ved oppdatering
  const updateCriteria = (newCriteria) => setCriteria({ ...criteria, ...newCriteria });
  // Gjør selve søkekallet med rtk-query e.l.
  const { data: searchResult, isLoading } = useSearchCaseQuery(criteria);

  return [criteria, updateCriteria, searchResult, isLoading];
}

Også bruker vi denne i en JSX komponent

const SearchPage = () => {
  const [criteria, updateCriteria, searchResult, isLoading] = useSearchPage();

  return (
    <PageLayout title="Cases">
      <SearchBar criteria={criteria} updateCriteria={updateCriteria} />
      <SearchOptions criteria={criteria} updateCriteria={updateCriteria} />
      <SearchResult isLoading={isLoading} result={searchResult} />
    </PageLayout>
  );
}

Dette fungerer greit. Blir litt knotete i komponenter som må lese kriteriet, men det er jo håndterbart. For eksempel støtten for å ekskludere status blir noe rotete både for å lese og skrive tilstand. Fort gjort å gjøre noe feil her som påvirker andre komponenter på en uforståelig måte:

const StatusFilter = ({ criteria, updateCriteria }) => {
  const includedStatus = criteria?.status;
  const excludedStatus = criteria?.subCriteria
    ?.filter(sc => sc.status && sc.exclude === true)
    ?.map(sc => sc.status);
  
  const setExcludeStatus = (status: string[]) => {
    updateCriteria({
      // hva om en annen komponent også legger til subCriteria??
      subCriteria: [
        { status, exclude: true }
      ]
    });
  };
  
  ...
}

Dette siste her blir jo litt tøysete og kan enkelt løses med at vi lagrer kriteriet som UI forventer det i tilstand og heller konverterer til API før vi gjør kallet.

const useSearchPage = () => {
  // kriterie slik UI trenger det - med for eks. excludeStatus property
  const [criteria, setCriteria] = useState({});
  ...
  // convertCriteria gjør om fra UI spesifikk excludeStatus til subCriteria...
  const { data: searchResult, isLoading } = useSearchCaseQuery(convertCriteria(criteria));
  ...
}

Da blir vår StatusFilter mye enklere og vil ikke føkke opp for andre komponenter:

const StatusFilter = ({ criteria, updateCriteria }) => {
  const includedStatus = criteria?.status;
  const excludedStatus = criteria?.excludeStatus;
  
  const setExcludeStatus = (status: string[]) => {
    updateCriteria({ excludeStatus: status });
  };
  
  ...
}

Superbra. Da gjenstår bare den lille saken med å sørge for at URL er oppdatert. Det som kan være fristende der er kanskje å lage en liten useEffect som fikser det når kriteriet endrer seg? Og leser inn evt kriterie fra URL ved oppstart. Kan se slik ut med react-router og query-string som mange bruker:

const useSearchPage = () => {
  const navigate = useNavigate(); // fra react-router
  const { search } = useLocation();
  // default state leses fra url tilfelle man kommer fra et ferdig lenket søk
  const [criteria, setCriteria] = useState(queryString.parse(search, QUERY_OPTS));
  ...
  useEffect(() => {
    navigate({
      search: queryString.stringify( criteria, QUERY_OPTS ) // query-string
    });
  }, [criteria]);
  ...
}

Da har vi en løsning som fungerer. Jeg har sett veldig mange implementasjoner som ligner på denne. Man har en eller annen global tilstand som UI endrer på - også oppdateres URL reaktivt samtidig som man må huske å lese fra URL ved første initialisering. Problemet er at jeg også har sett veldig mange bugs i denne typen løsning - mye fordi vi egentlig har to kilder til sannhet (både URL og global tilstand-saken). useEffect er ofte en kilde til feil - de beste løsningene er gjerne de som klarer seg uten. I BEKK har livet blitt ødelagt av dette - noe jeg kan kjenne meg godt igjen i.

EN kilde til sannhet → URL

De som har orket å lese seg helt ned hit har sikkert begynt å skjønne hvor jeg vil med dette her. Etter mye erfaringer med ulike varianter av tilstand knyttet til søkesider og andre sider som skal kunne lastes på nytt fra URL - har jeg funnet ut at det beste er å la selve URLen holde på all tilstand. Så da ender jeg opp med en hook for søkesiden som blir seende slik ut:

const useSearchPage = () => {
  const navigate = useNavigate();
  const { search } = useLocation();

  // her leser vi ut kriteriene fra URL hver gang den endres
  const criteria = queryString.parse(search, QUERY_OPTS);

  // oppdatering bare dytter ny info ut på URL
  const updateCriteria = (newCriteria) => {
    navigate({
      search: queryString.stringify(
        filterDefaults({
          ...criteria,
          ...newCriteria,
        }),
        QUERY_OPTS,
      ),
    });
  };

  const { data: searchResult, isLoading } = useSearchCaseQuery(convertCriteria(criteria));

  return [criteria, updateCriteria, searchResult, isLoading];
}

Kjempeenkelt. Vi får kode som er vesentlig enklere å vedlikeholde. Null bruk av useState og useEffect - to notoriske kilder til rare feilsituasjoner når de blir brukt kreativt. Den virkelige verden er selvsagt noe mer komplisert enn mitt eksempel - men prinsippene forblir helt like. Mye av kompleksiteten ligger i convertCriteria som enkelt kan enhetstestes. Og jeg kan konsentrere meg om å legge til ny søkefunksjonalitet i stedet for å finne ut hvorfor endring av ekskluderte kunder fjerner ekskluderte statuser eller andre snåle greier :)