WoodyLabBuild with UIMS

Documentazione · src/react/state

Lo stato

Nove file. Uno store con chiavi indirizzabili, slice dichiarate come dato, e reducer che il pacchetto esporta ma non installa.

Una chiave, un indirizzo

Lo stato è piatto: una mappa da chiave a valore. La chiave però può essere scopata per nodo — nodo-42:open — e lo scoping è quello che permette a tre accordion nella stessa pagina di avere ognuno il suo open senza collidere, e a un binding di puntarne uno preciso.

Le forme sono quattro: global (chiave piatta, condivisa da tutti), self (isolata sul nodo corrente), #<id> (un nodo preciso), owner (il contenitore, trovato invece che nominato). Il valore scopato resta comunque piatto nella mappa, il che tiene lo store semplice e le chiavi confrontabili per stringa.

Le prime tre sono indirizzi ASSOLUTI: chi li scrive deve sapere l’id dell’altro nodo, e dentro una lista quell’id va riscritto per ogni figlio. owner risale l’albero e prende il primo contenitore che DICHIARA quel nome — non il primo che ha uno stato qualsiasi, o un contenitore intermedio che espone altro se lo porterebbe via.

state/uiStore.ts

lo store: valori, dichiarazioni, commit, sottoscrizione per chiave

Come si osserva

const { open } = useSliceState(['nodo-42:open'])

Se non dichiari le chiavi

useUIState((s) => s)   // si sottoscrive a TUTTO lo store:
                       // ogni commit, ovunque, ridisegna questo nodo
state/expr.ts

lo scoping delle chiavi e la valutazione delle espressioni di binding

Come si chiama

scopeKey('self',   'open',  'nodo-42')   // 'nodo-42:open'
scopeKey('#hero',  'open',  'nodo-42')   // 'hero:open'
scopeKey('global', 'open',  'nodo-42')   // 'open'
scopeKey('owner',  'index', 'nodo-42', catena)   // 'carosello-7:index'

L'indirizzo relativo

// la catena arriva dal contesto, dal piu vicino al piu lontano
[{ id: 'slide1', names: ['active'] }, { id: 'carosello-7', names: ['index'] }]
// `owner` + `index` scavalca slide1: espone `active`, non `index`

Cosa resta vero

store.get()   // { 'nodo-42:open': true, 'hero:open': false, open: true }
              // la mappa non guadagna livelli: e la chiave a portare l'indirizzo

Ci si sveglia solo per le proprie chiavi

Un nodo reattivo dichiara quali chiavi guarda e si sottoscrive a quelle. Un commit su una chiave che non guarda non lo risveglia; un nodo che guarda zero chiavi — quello che ha solo un’animazione — non si sottoscrive affatto e non si risveglia mai.

L’ultimo caso è il più importante e il più facile da perdere: un nodo che si sottoscrivesse a tutto lo store verrebbe risvegliato da qualunque commit, in qualunque punto della pagina. La dichiarazione delle chiavi è ciò che lo impedisce.

Collegare le chiavi però non era una riga: useSyncExternalStore chiama lo snapshot durante il render e di nuovo dopo, per vedere se è cambiato. Un selettore che costruisce un oggetto nuovo a ogni chiamata non supera mai quel confronto e va in loop. La memoizzazione sta dentro l’hook, dove chi chiama non la può sbagliare.

state/useSliceControl.ts

l’hook che un componente interattivo usa per registrare e guidare la propria slice

Chiamata reale, dal wrapper del Collapsible

const [open, setOpen] = useSliceControl<boolean>({
  storeManaged,
  nodeId,
  name: 'open',
  defaultValue: !!defaultOpen,
  broadcastEvent: 'ui:openCollapsible',
})

Cosa scrive nello store

setOpen(true)   // commit sulla chiave <nodeId>:open
                // chi non guarda quella chiave non si sveglia

La forma delle slice è un dato

Quale stato espone un tipo di blocco è dichiarato in una mappa: nome, tipo, da quale campo prende il valore iniziale, quale evento lo cambia. È dato, non una scelta chiusa dentro il componente: il wrapper dell’accordion chiama l’hook con name: 'active', e quel nome sta scritto dove chiunque può leggerlo.

Averla come dato serve a due cose: l’admin può MOSTRARE quale stato un blocco espone, e lo store conserva la dichiarazione accanto al valore. Un undefined non distingue «chiave sbagliata» da «chiave vuota»; una dichiarazione sì.

La personalizzazione dei valori di default non è qui: qui c’è la forma e l’interazione. La customizzazione arriverà con lo spostamento della definizione nello Studio.

config/slices/blockSlices.ts

la mappa blocco → slice esposte, con tipo e campo di provenienza

La dichiarazione

export const BLOCK_SLICES: Record<string, SliceShape[]> = {
  accordion:   [{ name: 'active', type: 'string',  from: 'defaultValue', event: 'ui:openAccordion' }],
  collapsible: [{ name: 'open',   type: 'boolean', from: 'defaultOpen',  event: 'ui:openCollapsible' }],
  tabs:        [{ name: 'active', type: 'string',  from: 'defaultValue', event: 'ui:selectTab' }],
  swiper:      [{ name: 'index',  type: 'number',  from: 'initialSlide' }],
}

Come si legge

slicesOf('collapsible').map(describeSlice)
// ['open: boolean (da `defaultOpen`)']
state/defaults.ts

l’evento con cui l’hook scrive la slice di un nodo

I reducer li mette il progetto

UIStateProvider riceve i reducer attivi e non ne ha di default: il motore è vuoto. Il pacchetto li esporta ma non li installa — li installa il file app-state.ts che init scaffolda nel progetto, dove si vedono, si estendono e si possono togliere.

La scelta ha un costo dichiarato: Reducer, Wiring, Effect e Mutation sono contratto pubblico, perché è contro quei tipi che il consumer scrive la propria logica di dominio. Cambiare la loro forma è un breaking change, e questo va tenuto presente prima di cambiarla.

Una mutazione porta sempre una key, anche quando porta un path. Il path scrive su una foglia — sections.hero.title — e chi lo produce valorizza comunque key con la RADICE del percorso: è quella che lo store confronta e su cui sveglia chi ascolta. Il campo fa parte della forma pubblica, quindi si può contare su di esso.

state/UIStateProvider.tsx

il provider: reducer attivi, store, wiring

Il montaggio, dal progetto

<UIStateProvider
  reducers={{ ...defaultReducers, ACTIVATE_SECTION }}
  effects={stateEffects}
  persist="url"
>
  {children}
</UIStateProvider>

Il canale che non passi

dispatch({ type: 'ACTIVATE_SECTION', ... })
// nessun reducer risponde: nessun commit, nessun errore
state/index.ts

la porta dell’area

Gli effetti di pagina

Alcune conseguenze hanno bisogno del router: navigare, cambiare la query string. Le loro implementazioni non possono uscire da un componente, perché useRouter è un hook — quindi vengono registrate sul kernel dentro un useEffect, a runtime.

Al momento della CONFIGURAZIONE però un browser non c’è, quindi le implementazioni non esistono ancora: il plugin non avrebbe niente da leggere e l’admin non potrebbe proporle in un elenco. Da qui la separazione fra i NOMI, dichiarati staticamente, e le implementazioni, registrate quando un browser c’è.

state/pageEffects.ts

i nomi degli effetti di pagina, dichiarati staticamente

I nomi, leggibili a configurazione

export const PAGE_EFFECT_NAMES = ['refresh', 'reload', 'navigate'] as const
state/UIMSPageActions.tsx

le implementazioni, registrate sul kernel a runtime

Le implementazioni, vive solo a runtime

useEffect(() => {
  kernel.registerEffect('navigate', ({ payload }) => router.push(payload.href))
}, [kernel, router])
state/UIMSPageReactive.tsx

lo stato dichiarato a livello di PAGINA, non di blocco