02Frontend5 min

Kiedy globalny store zasługuje na swoje miejsce

Stanu globalnego nie usprawiedliwia wygoda. Usprawiedliwia go moment, w którym pośredni komponent dostaje propsy, których nie używa, tylko po to, by przekazać je niżej.

Autor: Salva Castells

Zdjęcie okładkowe: Fabian Horst, CC BY-SA 4.0.
W tym wpisie

W SaaS-ie do generowania obrazów miałem stronę z dwoma dziećmi: panel boczny z kontrolkami i dialog galerii. Strona nie rysowała nic własnego. Wyglądała tak:

export default function BoardPage() {
  const [attachedImages, setAttachedImages] = useState<AttachedImage[]>([]);
  const [galleryOpen, setGalleryOpen] = useState(false);
  const [galleryFilters, setGalleryFilters] = useState<GalleryFilters>({
    type: "all",
    models: [],
  });

  return (
    <div className="flex h-screen overflow-hidden">
      <Sidebar attachedImages={attachedImages} ... />

Strona nie używała żadnego z tych stanów. Deklarowała je, bo była wspólnym przodkiem tych dwóch, które ich używały. To prop-drilling w najbardziej szczerej postaci: stan wędruje w górę tam, gdzie najmniej przeszkadza, i schodzi rurą z propsów.

Konkretny sygnał, który każe mi migrować

To nie jest "jest dużo propsów". Jest bardziej konkretny: komponent dostaje propsa, nie czyta go i przekazuje w całości dalej.

W tym boardzie panel boczny dostawał selectedModels i onModelsChange tylko po to, żeby przesłać je do popovera z modelami. Nie zaglądał do nich. Strona dostawała galleryFilters z własnego useState tylko po to, żeby oddać je dialogowi. Za każdym razem, gdy dodawałem nowe pole generowania, dotykałem trzech plików i żaden z nich nie miał logiki związanej z tym polem.

Drugi sygnał, wynikający z pierwszego: typy propsów stają się lustrem stanu. GalleryDialogProps miało cztery wpisy — galleryOpen, galleryFilters, onGalleryClose, onGalleryFiltersChange — i żaden nie opisywał tego, co dialog robi. Opisywały, jak mu się to mówi.

Migracja to były cztery commity pod rząd w jedno popołudnie. Najpierw use-board-store.ts z galerią i dołączonymi obrazami oraz strona z niego korzystająca. Potem dzieci czytające ze store'a bezpośrednio i strona została taka:

export default function BoardPage() {
  return (
    <div className="flex h-screen overflow-hidden">
      <Sidebar />
      <GalleryDialog onImageClick={...} />

Zero propsów ze stanem. Komponent znów stał się tym, czym się nazywał: layoutem. Kiedy w trzecim commicie wsadziłem do store'a prompt, selectedAspectRatio, selectedModels i imageCount, panel boczny stracił 83 linie, a zyskał 9.

Czego nie wkładam do store'a

Po takiej migracji kusi, żeby wsadzić tam wszystko. Nie należy, i pokazuje to sam diff.

Popover modeli miał cztery propsy: open, onOpenChange, selectedModels, onModelsChange. Tylko dwa poszły do store'a. Pozostałe dwa zostały, ale zeszły niżej:

export function ModelsPopover() {
  const { selectedModels, setSelectedModels } = useBoardStore();
  const [open, setOpen] = useState(false);

Kryterium jest to, kto jeszcze musi czytać tę wartość. selectedModels czyta popover, czyta panel boczny, żeby policzyć szacowany koszt, i czyta selektor proporcji, żeby wiedzieć, które proporcje są dostępne. open nie czyta nikt poza samym popoverem. Gdybym wyniósł go do store'a, miałbym pięć booleanów xOpen w stanie globalnym bez żadnego powodu i bug z dnia, w którym dwa zostaną otwarte naraz.

Nie wkładam też do store'a niczego, co da się wyliczyć. Zestaw dostępnych proporcji to przecięcie tych, które wspiera każdy wybrany model. To funkcja selectedModels, więc mieszka tam, gdzie się jej używa, i jest przeliczana. Pole availableAspectRatios w store to byłaby kopia do utrzymywania w synchronizacji, a w dniu, w którym się rozjedzie, użytkownik zobaczy proporcję, której model nie przyjmuje.

  • Do store'a: to, co czytają dwie różne gałęzie drzewa.
  • Lokalnie w komponencie: to, co ulotne w interakcji — otwarte/zamknięte, hover, fokus.
  • Nigdzie: to, co da się policzyć z powyższych.

Edytor canvasu, gdzie drzewo jest płaskie i i tak nie ma store'a

Mniej więcej w tym samym czasie robiłem edytor planów biura, w stylu Figmy, ale do biurek, na Konvie. Tam intuicja mówi "to aż się prosi o store": jest płótno, pasek narzędzi, panel właściwości i wszystkie ruszają te same figury.

Nie ma store'a. Dalej jest useState w komponencie korzeniowym:

const [floorPlan, setFloorPlan] = useState<FloorPlan>(initialPlan || DEFAULT_FLOOR_PLAN);
const [editorState, setEditorState] = useState<EditorState>({
  selectedShapeId: null, tool: "select", zoom: 1, isDragging: false,
});

I działa, bo drzewo jest płaskie. Edytor ma troje bezpośrednich dzieci i każde dostaje to, czego naprawdę używa. Nikt niczego nie przekazuje dalej. Spełnia powyższe kryterium od dobrej strony: nie ma pośredniego komponentu, który dostaje propsy, których nie czyta.

Tym, co naprawdę bolało w tym edytorze, nie był stan, tylko eksplozja typów. Było dziewięć wariantów biurka jako osobne typy: dual-desk-facing, quad-desk-parallel, hex-desk-facing, octo-desk-parallel. Dziewięć identycznych interfejsów poza nazwą, dziewięć gałęzi w switch płótna, dziewięć plików komponentów. Refaktor, który to naprawił, zwinął je do dwóch, multi-desk-facing i multi-desk-parallel, z polem seats:

{Array.from({ length: seats }, (_, index) => (
  <SingleDesk key={index} ... />
))}

Skasował siedem plików i zdjął 453 linie z komponentu rysującego plan w widoku rezerwacji. Żaden store by tego nie naprawił, bo problemem nigdy nie było to, gdzie mieszkał stan, tylko ile miał kształtów.

Co się zmienia, gdy stan wychodzi z propsów

Traci się coś realnego: patrząc na sygnaturę komponentu, już nie wiesz, od czego zależy. Trzeba go otworzyć. Testy nie mogą wstrzykiwać stanu przez propsy i muszą przygotować store. I znika tarcie, które ostrzegało cię, że sprzęgasz za bardzo — rura propsów była brzydka, ale była uczciwym licznikiem sprzężenia.

Dlatego store zasługuje na swoje miejsce, a nie dostaje je w prezencie. Pytaniem nie jest, ile jest propsów. Pytaniem jest, czy jakiś komponent po drodze dostaje takiego, którego nie używa.