Implementare il LeavesFeatureNavigator: navigator + Context pattern

navigator-pattern

12 minuti DI LETTURA

Quando una feature React Native contiene più schermate correlate, il problema non è soltanto decidere quale componente renderizzare. Bisogna anche stabilire chi possiede la navigazione, chi effettua le chiamate API, e come arrivano a ogni schermata tema, alert e callback.

Se ogni screen importa direttamente i servizi dell’app, il package smette di essere riutilizzabile. Se invece tutte le dipendenze vengono passate come props attraverso ogni livello, il codice finisce per soffrire di prop drilling. Il pattern usato da feature-leaves combina due strumenti: uno Stack interno creato con createNativeStackNavigator, e un LeavesContext che distribuisce le dipendenze della feature.

In questo articolo vediamo come funziona il pattern, quali contratti richiede e quali errori evitare.


Il confine tra shell e feature

La shell dell’app conosce provider, autenticazione, store, tema globale e stack principale. La feature ferie conosce invece richieste, contatori, archivio, approvazioni e form. Il confine desiderato è:

App shell
  ├── apiClient
  ├── Redux
  ├── AlertModalProvider
  └── WiredLeavesNavigator
          ↓ contracts
      feature-leaves
          ├── screens
          ├── hooks
          ├── components
          └── internal navigator

Il package può chiamare onGetLeaves, ma non deve sapere se dietro c’è fetch, un client custom o un mock. Può chiamare onShowError, ma non deve importare ErrorModalProvider. Questa è dependency injection: le dipendenze vengono fornite dall’esterno, e consumate attraverso un contratto.


Struttura del package

La struttura rilevante è:

feature-leaves/
└── src/
    ├── components/
    ├── context/LeavesContext.js
    ├── contracts/ILeavesContracts.js
    ├── hooks/
    ├── navigation/LeavesFeatureNavigator.js
    └── screens/

Le schermate importano componenti e hook relativi. Gli hook leggono dal Context soltanto le callback necessarie. Il file di navigazione non contiene logica di business. Questa separazione permette di leggere il flusso dall’alto verso il basso: il navigator registra le rotte, il Context fornisce le dipendenze, le screen compongono la UI, gli hook coordinano stato e azioni, e la shell implementa il contratto.


Definire LeavesContext

Il Context è minimale:

import { createContext, useContext } from 'react';

export const LeavesContext = createContext({});

export const useLeavesContext = () => useContext(LeavesContext);

Il valore di default vuoto evita un’importazione globale obbligatoria. In sviluppo, però, è utile rilevare presto una dipendenza mancante. Un helper può controllare le callback essenziali:

export function assertLeavesContracts(contracts) {
  const required = ['onGetLeaves', 'onSendLeaveRequest'];
  const missing = required.filter(key => typeof contracts?.[key] !== 'function');

  if (missing.length > 0) {
    throw new Error(`Missing Leaves contracts: ${missing.join(', ')}`);
  }
}

Il controllo va usato con criterio: alcune callback possono essere opzionali in una determinata configurazione o in un test.

Sono candidati corretti per il Context: callback API, callback per alert ed errori, tema o token di design, callback di sincronizzazione verso la shell, configurazioni specifiche della feature. Non lo sono, invece: token JWT, l’intero Redux store, oggetti globali non usati dalla feature, o riferimenti a componenti della shell senza contratto.



Registrare le sette schermate

La registrazione completa è esplicita:

<Stack.Screen name="LeavesRequestScreen" component={LeavesRequestScreen} />
<Stack.Screen name="HolidayRequestListScreen" component={LeavesRequestListScreen} />
<Stack.Screen name="HolidayRequestDetailScreen" component={LeavesRequestDetailScreen} />
<Stack.Screen name="LRMContatoriDetailScreen" component={LRMContatoriDetailScreen} />
<Stack.Screen name="LeavesRequestFormScreen" component={LeavesRequestFormScreen} />
<Stack.Screen name="PendingApprovalsScreen" component={PendingApprovalsScreen} />
<Stack.Screen name="LeavesArchiveScreen" component={LeavesArchiveScreen} />

I nomi sono parte dell’API interna di navigazione, e vanno usati con coerenza:

navigation.navigate('LeavesRequestFormScreen', {
  autoOpenSelector: true,
  configData,
});

Un refactor dei nomi deve essere accompagnato dalla ricerca di tutti gli utilizzi, e da test di navigazione dedicati: una stringa sbagliata produce un errore soltanto a runtime.


Consumare il Context nelle schermate

Un hook come useLeavesArchive può leggere le callback senza importare il backend:

export function useLeavesArchive() {
  const { onGetLeaves, onUpdateLeaveStatus } = useLeavesContext();
  // stato, filtri, paginazione e azioni
}

La schermata conserva un ruolo presentazionale e di composizione:

export default function LeavesArchiveScreen() {
  const { theme } = useLeavesContext();
  const data = useLeavesArchiveScreen();

  return <ArchiveList theme={theme} {...data} />;
}

Il componente non deve decidere quale URL chiamare, e non deve nemmeno sapere come Redux aggiorna lo stato globale. LeavesRequestFormScreen segue la stessa logica: riceve parametri di navigazione, chiama l’hook useLeavesRequestForm, e renderizza FerieForm o PermessiForm in base alla causale. Il form può usare onSendLeaveRequest dal Context, mentre la shell decide quale implementazione fornire.


Definire e validare il contratto

Il Context è un meccanismo; il contratto è la documentazione delle dipendenze. È utile raggruppare le callback per area:

Config e utente
- onGetLeaveScreenConfig
- onGetUserInfos

Richieste
- onSearchLeaveRequests
- onSendLeaveRequest
- onDeleteLeaveRequest

Archivio e approvazioni
- onGetLeaves
- onUpdateLeaveStatus

Contatori e form
- onGetContatoriFeriePermessi
- onGetTypeSelectorOptions
- onGetMooreaObj

Il contratto dovrebbe precisare parametri, risultato, errori e callback opzionali:

onUpdateLeaveStatus: async (id, status) => {
  // status: valore semantico convertito nel formato richiesto dal backend
}

È meglio che il package lavori con intenti leggibili, e che la conversione verso numeri o URL avvenga direttamente nel contratto app.


Collegare la shell con il wiring

La shell implementa i contratti HTTP in appLeavesContracts, e aggiunge dipendenze locali:

const contracts = {
  ...appLeavesContracts,
  onShowAlert: (title, message) => showAlert(title, message),
  onShowError: error => showError(error),
  onContatoriFetched: result => dispatch(setContatori(result)),
  onConfigLoaded: result => dispatch(setLeaveScreenConfig(result)),
  theme,
};

return <LeavesFeatureNavigator contracts={contracts} />;

Questa composizione evita che il package debba conoscere endpoint come /get_leaves o /crea_giustificativo, la struttura delle slice, i provider modali, o la configurazione del tema dell’app. Il punto di wiring è anche il posto corretto per logging applicativo, metriche e adattamento dei dati, senza inserire queste responsabilità nelle screen.


Testare il navigator

Il test minimo deve montare il navigator con dipendenze simulate:

const contracts = {
  onGetLeaves: jest.fn().mockResolvedValue([]),
  onSendLeaveRequest: jest.fn().mockResolvedValue({ success: true }),
};

render(<LeavesFeatureNavigator contracts={contracts} />);

I test possono verificare la presenza della screen iniziale, l’accesso al Context da parte di una screen, la navigazione verso il form, la chiamata alla callback corretta, il comportamento con una lista vuota, e lo stato di errore quando una callback rifiuta la Promise. Non serve testare in ogni test l’intera app shell: il package deve essere testabile con il proprio contratto mock, punto.


Errori comuni

Importare il client HTTP nel package crea accoppiamento, e rende difficile sostituire il backend. Creare un Context globale per tutta l’app fa perdere il beneficio del confine: il Context deve restare limitato alla feature, e avere un contratto leggibile. Nascondere dipendenze obbligatorie è rischioso: se una screen richiede una callback, il contratto deve dichiararlo — un errore silenzioso in onSendLeaveRequest?.() può trasformarsi in una richiesta che semplicemente non parte. Usare parametri di rotta come stato globale confonde due ruoli diversi: le rotte devono trasportare intenti e dati minimi, non sostituire un repository o uno store. Dimenticare il container principale, infine, è un errore strutturale: il navigator interno va sempre annidato nel container dell’app, senza crearne uno nuovo senza una ragione specifica.


Domande frequenti

Context e dependency injection sono la stessa cosa?

No. Context è il meccanismo React; dependency injection è il principio per cui le dipendenze vengono fornite dall’esterno.

Il package può usare un tema condiviso?

Sì. Il tema può arrivare dal Context o da un design system comune, senza importare configurazioni private della shell.

Posso esportare anche le singole schermate?

È possibile, ma l’entry point principale dovrebbe restare il navigator. Esportare screen singole va fatto solo quando esiste una necessità architetturale documentata.

Come gestisco i deep link?

La shell deve definire il deep link globale, e tradurlo nella rotta interna corretta, senza esporre direttamente ogni dettaglio del package.

Conclusione

LeavesFeatureNavigator e LeavesContext risolvono due problemi diversi ma collegati tra loro. Il navigator governa il flusso delle sette schermate; il Context distribuisce contratti e valori condivisi senza prop drilling.

La shell resta responsabile del wiring, mentre il package resta cieco rispetto a HTTP, Redux e provider globali. È questo confine, più che il singolo componente, a rendere la feature riutilizzabile e testabile.

Costruiamo insieme il form della richiesta Ferie nel prossimo articolo.