Skip to content

State Stores

Pablo Martínez López edited this page Oct 27, 2026 · 11 revisions

State Stores

English · Español

All frontend state lives in Zustand stores. Seven global stores in src/stores/ cover the open deck, the page plan, screen state and preferences, the running long job, the app-update state machine, the card being designed with unsaved changes and the game table's view; a handful of area stores live beside their feature. The stores never compute layout or do heavy work: they hold what Rust returned, call Rust through call(), and expose actions to the components.

Overview

Store (hook) File Persistence One-line role
useDeck src/stores/deck-store.ts Rust (SQLite) via deck_save, debounced autosave The open deck, undo/redo history, save state.
usePlan src/stores/plan-store.ts none (derived) The PagePlan computed by Rust for the open deck.
useUi src/stores/ui-store.ts localStorage key proxybuilder.preferencias (preferences subset only) View, zoom, selection, open dialog, open tutorial, last action, list preferences.
useTrabajo src/stores/trabajo-store.ts none The one long job in flight (resolve a list, export a PDF, complete a design language or prepare the game table).
useActualizacion src/stores/actualizacion-store.ts none In-app update state machine.
useEdicion src/stores/edicion-store.ts none The card being designed, when it has unsaved changes, and how to save it.
useMesa src/stores/mesa-store.ts none (the game lives in Rust) The game table's view as Rust sends it, patched in place, plus screen-only selection, hover, inspection and log.
usePantalla src/features/mesa/pantalla.ts none Game table screen state: open table dialog, target mode, drag in progress, zone under the pointer.
useTemasInstalados src/lib/temas-instalados.ts localStorage key proxybuilder.temasInstalados (mirror) Installed tweakcn themes; see Themes and Skins.
useBiblioteca, usePrefsBiblioteca, useFiltrosBiblioteca src/features/library/store.ts, preferencias.ts Rust for data; proxybuilder.biblioteca for view prefs; filters not persisted Library data and view; see Deck Library.
useMarcosOriginales, usePacksDescargables src/features/disenar/marcos-originales.ts, packs-descargables.ts none Download status/progress of downloadable frame packs; see Frame Packs.

Theme and language state are not Zustand: they are React contexts (ThemeProvider, LanguageProvider) backed by localStorage; see Themes and Skins and Internationalization.

Data flow

flowchart LR
    UI["Components"] -->|"aplicar / setCartas / setAjustes"| DS["useDeck (deck-store)"]
    DS -->|"subscribe: version changed"| AG["iniciarAutoguardado (800 ms)"]
    AG -->|"call deck_save"| R["Rust: SQLite + blobs"]
    DS -->|"subscribe: cards / settings / defaultBack changed"| PS["iniciarPlan (100 ms)"]
    PS -->|"call compute_page_plan"| R
    R -->|"PagePlan"| PL["usePlan"]
    PL --> PV["PreviewPane / footer"]
    UI -->|"seleccionar, abrir, setZoom"| UIS["useUi"]
    UIS -->|"localStorage proxybuilder.preferencias"| LS[("localStorage")]
    UI -->|"empezar / avanzar / terminar"| TS["useTrabajo"]
    R -->|"events resolve:// export://"| LT["lanzarTrabajo (lib/trabajos.ts)"]
    LT --> TS
    UPD["useActualizacion"] -->|"call update_check / update_download / update_install"| R
    UPD -->|"guardarAhora before install"| DS
    DSG["DisenarScreen"] -->|"poner({ guardar }) while there are unsaved changes"| ED["useEdicion"]
    ED -->|"pendiente.guardar()"| DSG
    CC["ConfirmarCierre / AppShell"] -->|"pendientes() reads trabajo, paso, edicion"| TS
    CC --> UPD
    CC --> ED
    CC -->|"partida: useMesa.activa"| MS
    R -->|"Channel MensajeMesa: Vista / Parche"| MS["useMesa (mesa-store)"]
    UI -->|"hacer(accion): call mesa_accion"| R
    CC -->|"guardarAhora on close"| DS
Loading

Two derived loops are started once, from useDeckInicial() (src/features/shell/use-deck-inicial.ts): iniciarAutoguardado() and iniciarPlan(). Both subscribe to useDeck outside React and return a stop function used on unmount.

useDeck: the open deck (src/stores/deck-store.ts)

Shape: { deck: Deck | null, guardado: EstadoGuardado, errorGuardado, version: number, pasado: Instantanea[], futuro: Instantanea[] } plus actions.

Deck is the generated type (src/bindings/Deck.ts): id, name, decklistText, cards: DeckCard[], settings: PrintSettings, defaultBack, schemaVersion (model details in Deck Model).

EstadoGuardado = "saved" | "saving" | "dirty" | "error" (rendered by src/features/shell/save-indicator.tsx, labels "Saved / Saving… / Unsaved / Couldn't save").

Actions

Action Behaviour
cargar(deck) Replaces the deck and resets guardado: "saved", pasado/futuro: [], version: 0.
aplicar(cambio) The single mutation path with history. cambio(deck) returns a Partial<Deck>; the previous Instantanea (cards, settings, defaultBack) is pushed to pasado (capped at MAX_HISTORIAL = 100), futuro is cleared, guardado = "dirty", version++.
setCartas(f), setAjustes(f) Convenience wrappers over aplicar for the card list and PrintSettings.
setSinHistorial({ name?, decklistText? }) Changes that are saved but not undoable (deck name, the list text). Bumps version, marks dirty, no snapshot.
refrescarCartas(antes, nuevas) For system-initiated card updates that are not user edits (re-rendered design thumbnails, src/features/disenar/renumerar.ts rehacerMiniaturas). Replaces only cards whose current object is still the same reference as in antes, so a user edit made meanwhile wins. No history; marks dirty.
deshacer() / rehacer() Pop from pasado / shift from futuro, swapping with the current snapshot; mark dirty. Do not touch name or decklistText.
_marcarGuardado(estado, error?) Internal: used by the autosave and guardarAhora.

Invariants:

  • Never call deck_save from a screen. All persistence goes through iniciarAutoguardado() (or guardarAhora() right before leaving the deck).
  • Any change must go through aplicar/setSinHistorial/refrescarCartas so version increments; both derived loops key off it.
  • History holds whole cards array references (structural sharing), not deep copies, so 100 entries are cheap. Edits must therefore be immutable (use the pure helpers in src/lib/deck-ops.ts: mover, moverGrupo, duplicar, quitar, insertar, actualizar, huecos).

Autosave: iniciarAutoguardado(retardoMs = 800)

  1. Subscribes to useDeck; when version changed and guardado === "dirty", (re)starts an 800 ms timer (debounce).
  2. On fire, guardar() reads { deck, version }; if a save is already in flight it sets pendiente = true and returns (only one deck_save in flight).
  3. Sets "saving", await call("deck_save", { deck }), then sets "saved" only if useDeck.getState().version === version (no change arrived during the save), else "dirty".
  4. On failure: "error" with the error kept in errorGuardado (the indicator's tooltip shows mensaje(error)). The retry happens with the next change (the subscriber needs guardado === "dirty", which aplicar sets); there is no automatic retry loop.
  5. In finally: if pendiente or the state is "dirty", schedule another round with the newest version.

Related helpers in the same file:

Function Use
abrirDeckInicial(nombreNuevo) Boot: reads app setting last_deck_id (app_setting_get), tries deck_open, falls back to deck_create, writes last_deck_id, then cargar. A stale id (deleted deck) silently falls back to a new deck.
guardarAhora() Flushes pending changes immediately (deck_save, then "saved" if the version did not move). Used before switching deck (abrirDeck), before loading the library (useBiblioteca.cargar), before installing an update and on every close request of the window (ConfirmarCierre, so a change made inside the 800 ms autosave wait is not lost; a failure there becomes a reason to ask, see Settings and Shell).
abrirDeck(id) guardarAhora() then deck_open, remember last_deck_id, cargar.

Undo shortcuts (Ctrl/Cmd+Z, Ctrl/Cmd+Shift+Z) are registered in useDeckInicial and skip focused inputs/textareas/contenteditable so text fields keep their own undo.

Persistence side: deck_save is the Rust command in src-tauri/src/commands/library.rs returning SaveResult { updatedAt }; storage is described in Storage and Blobs.

usePlan: the page plan (src/stores/plan-store.ts)

Shape: { plan: PagePlan | null, error: unknown, deDeck: Deck | null }. deDeck is the exact deck object the plan was computed from, so consumers can map a cell index to the card that existed when Rust computed it.

iniciarPlan(retardoMs = 100):

  • Subscribes to useDeck. If the deck object is the same, nothing. If the deck id changed or it became null/non-null, request immediately (programar(true)). Otherwise request after 100 ms only if cards, settings or defaultBack changed by reference (name and list text do not affect layout).
  • A monotonically increasing peticion counter implements "last request wins": a late response from an older request is dropped. On error only error is set; the previous plan stays visible.
  • Rust command: compute_page_plan({ deck }) (src-tauri/src/commands/layout.rs); geometry is in Layout Engine. Nothing on the screen recomputes positions; PagePlan is in millimetres and the preview multiplies by zoom (see Live Preview).

useUi: screen state and preferences (src/stores/ui-store.ts)

Two groups of fields in one store.

Session-only (not persisted):

Field Meaning
vista: "deck" | "biblioteca" | "disenar" | "mesa" / setVista Which main view AppShell shows.
disenando: string | null / disenar(id) Card id being designed; disenar(id) also switches vista to "disenar".
verReversos: boolean / setVerReversos Preview shows card backs.
tutorial: Vista | null / abrirTutorial(v) The screen whose tutorial is open. abrirTutorial(v) also appends v to tutorialesVistos (once) and saves; abrirTutorial(null) closes. See Tutorials.
seleccion: string[], ancla: string | null / seleccionar(ids, ancla?) Selected card ids and the last clicked card (anchor for Shift ranges).
dialogo: Dialogo | null / abrir(d) The open global dialog, a discriminated union on tipo: editar, variante, vincular (each with id), vincularLote (ids?, soloSeleccion?), anadir (en? insert position), moverA (ids), fichas, sets, estadisticas, colecciones, recursosDiseno, disenarDeck, exportarPng.
ultimaAccion: string | null / anunciar(texto) Footer message ("PDF saved (6 pages)").

Preferences (type Preferencias, persisted):

Field Default Meaning
lang "en" Language used to fetch card printings (Scryfall language code: en es fr de it pt ja ko ru zhs zht, the list IDIOMAS_SCRYFALL of src/lib/idiomas.ts). This is not the interface language (that is proxybuilder.lang, see Internationalization). Sent as ResolveOptions.lang and used by the add-card dialog.
imagePolicy "withImage" ImagePolicy = bestAvailable / withImage / highresOnly: which printing is chosen when the list gives no set.
addTokens false Add related tokens when resolving a list.
calidadVista 488 Preview thumbnail width (AnchoMiniatura; the selector offers 244 / 488 / 672 / 745). Screen only; the PDF always uses export resolution.
pedirConfirmacionAlCargar true Ask before replacing existing cards when loading a list.
zoom 0.8 Preview zoom, clamped to [ZOOM_MIN 0.4, ZOOM_MAX 2.5] and rounded to 0.01 in setZoom.
infoCarta false Show per-card info badges in the preview.
actualizarSola true Auto-check/download updates (see Settings and Shell).
tutorialesVistos [] Screens (Vista) whose tutorial has already been shown; useTutorialInicial only opens a tour by itself for a screen not in the list. See Tutorials.
mesa MESA_DE_FABRICA PreferenciasMesa, the game table settings (card size, quality, large view, status panel, animations, shadows, frame counter, sounds of the cards, the turn and the chat, hold-to-open menu, bonus offer, phantom, trackers, live movement online, following the turn in a local game and the layout of the boards per number of players). Changed with setMesa(partial); read back merged over MESA_DE_FABRICA so a new field gets its default. See Game Table.

Persistence: leer() merges the stored JSON over PREDETERMINADAS, so adding a new preference never breaks older stored objects; guardar() writes only the Preferencias keys (never vista, selection or dialog) and is called by setZoom, setInfoCarta, setPreferencia(k, v), setMesa and abrirTutorial. Both are wrapped in try/catch (private mode, blocked storage): the app then simply runs on defaults for the session. setVerReversos is deliberately not persisted.

Guard: src/stores/__tests__/ui-store.test.ts (defaults, merge over stored, broken JSON, blocked storage, zoom clamp, the exact set of persisted keys, a tutorial marked as seen once and remembered while the open one is not, and the table settings merged with their defaults).

useTrabajo: the running long job (src/stores/trabajo-store.ts)

actual: EstadoTrabajo | null where EstadoTrabajo = { tipo: TipoTrabajo, jobId: number | null, fase: string, hechas, total }. Actions: empezar(tipo, fase), setId(jobId), avanzar(fase, hechas, total), terminar(). Only one job at a time: resolver() in src/features/decklist/acciones.ts throws { code: "Busy" } if actual is set; fase is already translated text. The left panel footer and the export button read it; TipoTrabajo is "resolver" | "exportar" | "idioma" | "mesa"; "mesa" is preparing a game (mesa_empezar, mesa_retomar, see Game Table); "idioma" is the design_complete_lang job started by completarIdioma (src/features/disenar/renumerar.ts) when the deck switches to a design language its designs lack, or before a PNG export in such a language (see Designer UI). cancelarTrabajo() (src/features/decklist/acciones.ts) calls export_cancel for "exportar" and job_cancel otherwise, with jobId. The jobId is null until lanzarTrabajo returns (see Frontend Architecture). On the Rust side the matching guard is AppState::ocupado(), which also makes update_install return Busy so an update never kills a PDF export; it is also true while a game is open on the table.

Other long operations (identify batch, design PNG export, original-frames download) keep their own local progress state instead of useTrabajo.

useActualizacion: in-app update machine (src/stores/actualizacion-store.ts)

State: soportada: boolean | null, actual (running version), paso, info: ActualizacionInfo | null, progreso: number | null (0 to 100), error.

stateDiagram-v2
    [*] --> inactiva
    inactiva --> buscando: buscar()
    buscando --> alDia: no newer version
    buscando --> disponible: update_check found one
    buscando --> inactiva: error (no info)
    disponible --> bajando: bajar()
    bajando --> lista: update_download ok
    bajando --> disponible: download error
    lista --> instalando: instalar()
    instalando --> lista: install error (can retry)
    instalando --> [*]: app restarts
Loading

(alDia is the code value "al-dia".)

Action Behaviour
iniciar() Asks Rust once (update_status) whether this build can update (soportada is false in dev or when the binary did not come from an installer); errors mean unsupported.
buscar() Ignored while buscando/bajando/instalando. If already lista returns the downloaded info (no re-download until restart). Calls update_check.
bajar() Only from disponible. Subscribes to update://progress ({ hecho, total }, total 0 = unknown size) to compute progreso, calls update_download({ version }), always unsubscribes.
instalar() Only from lista. with the table open (useMesa.activa) it only sets alSalirDeLaMesa (the install runs when the table closes, from useAvisosActualizacion); otherwise guardarAhora() first (flush the deck), then update_install (a Busy motivo: partida also sets alSalirDeLaMesa); if it succeeds Rust restarts the app and the promise never settles; on error returns to lista and rethrows so the caller toasts it.

The Rust side (src-tauri/src/actualizar.rs, src-tauri/src/commands/actualizar.rs) and the release pipeline are documented in Releases and Auto Updates; the UI consumers (toasts, bar button, dot, Settings section) are in Settings and Shell.

useEdicion: the card with unsaved changes (src/stores/edicion-store.ts)

State: pendiente: { guardar: () => Promise<boolean> } | null and the action poner(p). It is a tiny registration slot, not a copy of the draft: DisenarScreen (the Design view) puts a guardar callback in it while its draft has unsaved changes and clears it when they are saved, discarded or the screen unmounts (see Designer UI). guardar returns false when saving failed; the failure was already shown to the user, so callers just do not continue.

Why a store: the things that can interrupt Design (changing view in AppShell, closing the window in ConfirmarCierre) live elsewhere and must be able to say "Save and continue" without knowing how a card is saved (design_save, applying to the other copies, announcing "Diseño guardado"). pendiente !== null is also the signal that there is something to lose: lib/cierre.ts reads it as the edicion reason. No persistence, no history.

useMesa: the game table (src/stores/mesa-store.ts)

The view Rust sends (mesa::vista::VistaMesa), normalised by key: cartas (Record<IdCarta, VistaCarta>), zonas (by claveZona(RefZona): "<player>:<zone>" or "c:<zone>"), jugadores, asientos (seat order), general, plus version, yo, formato, variantes, opciones. Screen-only fields that never travel to Rust: activa (a game is open and the screen is subscribed), registro (the last MAX_REGISTRO = 500 events, for the log), seleccion, sobre (the card under the pointer, for the large view and shortcuts) and inspeccion.

Action Behaviour
recibirVista(v, registro?) Replaces everything with desdeVista(v); keeps only the selection and the hovered card that still exist. With registro (a full view from the channel: subscribing, undo, restart) it also replaces the log with the events that still count; without it (mesa_vista after a gap) the log stays.
recibirParche(p, evento) conParche(m, p): only if p.version === version + 1; copies just the touched maps and leaves untouched cards as the same objects (their memoised components do not repaint). Returns false on a gap, and conexion.ts then asks for mesa_vista. Appends the event to registro and drops removed cards from selection and hover.
cerrar() Back to MESA_VACIA, activa: false.
seleccionar, alternar, setSobre, inspeccionar Screen state.

Rules: it never recomputes what Rust decides (who sees what, positions); selectors that may return nothing return the shared NINGUNA array (a fresh [] would look like a change on every read), and selectors that build a list use useShallow. Helpers cartasDe(m, zona) and cuantasEn(m, zona) read a zone. The subscription itself lives in src/features/mesa/conexion.ts. Guard: src/stores/__tests__/mesa-store.test.ts, fed by a fixture the engine writes (src/stores/__tests__/fixtures/mesa-parches.json). See Game Table.

Area stores worth knowing

  • useBiblioteca (src/features/library/store.ts): decks, detalles (per-deck computed details), etiquetas; every action calls Rust (deck_tags_set, deck_pin, deck_lang_set, deck_notes_set, deck_cover_set, tag CRUD) and then re-reads (refrescar). cargar() begins with guardarAhora() so the library never shows stale data for the open deck. Details in Deck Library.
  • usePrefsBiblioteca: view (cuadricula/lista), sort, direction, grouping, card size, sidebar, favourites-first; persisted under proxybuilder.biblioteca. useFiltrosBiblioteca: filters, intentionally not remembered.
  • usePantalla (src/features/mesa/pantalla.ts): the table's open dialog (DialogoMesa, one at a time), the target mode (adjuntar or flecha), the drag in progress (Arrastre, which the dragged copy follows without waiting for Rust; the large view hides while it is set), the zone under the pointer (so library shortcuts work) and tapeteCambiado, a counter avisarTapete() raises when the mat dialog saves, so the board reads the mat again. pedirNumero opens the number prompt.
  • useChat (src/stores/chat-store.ts): the table's chat on this screen only — silenciados (players muted for me; the event still travels), bocadillos (the last message or notice of each player, for BOCADILLO_MS), sinLeer (unread counter of the tab) and limpiar() when the table closes. useRepeticion (src/stores/repeticion-store.ts): the replay in progress (activa: VistaRepeticion or null) and its playback (reproduciendo, velocidad); while it is set the table is read-only. usePantalla also keeps the side panel (registro, chat or none) and ANCHO_PANEL.
  • useMarcosOriginales / usePacksDescargables: info/lista, progreso, and a version counter that increments after a successful download so lists of packs re-fetch.

Persistence keys at a glance

Where Key Owner
Rust app_settings last_deck_id deck-store.ts (abrirDeckInicial, abrirDeck)
Rust app_settings carpeta_importar, carpeta_exportar commands/carpetas.rs via lib/dialogos.ts
localStorage proxybuilder.preferencias useUi
localStorage proxybuilder.lang LanguageProvider
localStorage proxybuilder.theme, .mode, .scale, .skin, .bg, .material, .blur, .noise, .shadow, .fxSidebar, .heraFiligree, .smoothScroll ThemeProvider
localStorage proxybuilder.temasInstalados lib/temas-instalados.ts
localStorage proxybuilder.biblioteca usePrefsBiblioteca
localStorage proxybuilder.paneles, proxybuilder.panel-lista, proxybuilder.paneles.disenar react-resizable-panels layouts
localStorage proxybuilder.seccion.<id> Seccion collapsed state
localStorage proxybuilder.disenar.coleccion last collection used for new design drafts (features/disenar/use-borrador.ts)
localStorage proxybuilder.mesa.nombre the player name of the game table (features/mesa/entrada.tsx)
Rust app_settings mesa.tapete.<deck id>, mesa.tapete the mats of the game table (src-tauri/src/mesa/tapete.rs)

Rule of thumb: anything a deck depends on goes in the deck (Rust); anything that is a per-machine convenience goes in localStorage wrapped in try/catch; anything Rust needs to remember across sessions (last deck, last folders) goes in app_settings.

Tests that guard the stores

src/stores/__tests__/: deck-store.test.ts (five rapid changes cause one save; undo three times and redo restore exact state; a failed save leaves error and retries on the next change; non-history changes are saved but not undoable), deck-store-abrir.test.ts, plan-store.test.ts (immediate first request, debounce, last-request-wins with mocked call), ui-store.test.ts, trabajo-store.test.ts, actualizacion-store.test.ts, mesa-store.test.ts (patches over the first view give the final view, a version gap asks for the view, untouched cards keep their identity, the selection loses removed cards, a view with a log replaces it and one without keeps it). The close guard that reads useTrabajo, useActualizacion, useEdicion and useMesa is tested in src/lib/__tests__/cierre.test.ts (see Testing and CI). They use fake timers and mock @/lib/ipc; localStorage is stubbed because Vitest runs in the node environment.

Where to start when extending

  • New undoable deck change: call useDeck.getState().setCartas(...)/setAjustes(...) with a pure helper; do not mutate. If it is not a user edit, use refrescarCartas.
  • New preference: add the key to Preferencias and PREDETERMINADAS, and to the object built in guardar() of ui-store.ts; read with useUi((s) => s.key). A game table setting goes in PreferenciasMesa and MESA_DE_FABRICA instead.
  • New global dialog: add a Dialogo variant, mount the component in src/App.tsx.
  • New long job: use lanzarTrabajo and mirror progress into useTrabajo if the footer/export button should show it.

See also

Documentation · Documentación

Language: English · Español

Use and build · Uso y desarrollo

Architecture · Arquitectura

Data · Datos

Getting cards · Conseguir cartas

Layout, images, PDF · Maquetación, imagen y PDF

Card designer · Diseñador de cartas

Game table · Mesa de juego

Frontend · Frontend

Reference · Referencia

Clone this wiki locally