Cette semaine je me suis retrouvé dans un cas où j'ai eu exactement ce cas à écrire : un système de contexte global partagé en TypeScript et RxJS. L'idée étant qu'on a plusieurs briques différentes dans l'application, mais on a quelques éléments qui doivent être partagés quand ils sont disponibles, ces éléments pouvant être mis à jour au fil de l'exécution, il faut aussi que les différentes briques soient au courant de changement en direct pour rester à jour sur les informations du contexte global.

On a fait le choix de TypeScript (on fait du frontend, JavaScript ne donne pas autant d'assurance sur la qualité du code) et RxJS (pour avoir un système réactif fiable), mais on aurait pu partir sur d'autres choix technologiques. Ici je vous montre une manière de faire en TS et RxJS pour l'exemple, je vous laisse adapter pour vos technologies préférées 😇

Je vais dérouler l'exercice par itération en expliquant ce qui est bien et pas bien dans les différentes solutions, ainsi que pourquoi ça fonctionne.

J'ai regroupé toutes les étapes que je montre dans cet article sous une forme parfaitement fonctionnelle sur ce dépôt github https://github.com/kuroidoruido/rxjs-context-demo. Vous y trouverez aussi un Stackblitz pour jouer avec le code directement sur votre navigateur 😎

Ce qui est attendu

Ce qu'on attend ici c'est en premier lieu de pouvoir récupérer le dernier état du contexte au moment où on s'abonne aux mises à jour. On veut aussi être informé des changements sur cet abonnement. On veut pouvoir éditer le contenu du contexte.

1. Solution naïve

// context.ts
import { BehaviorSubject } from 'rxjs';

export const context$ = new BehaviorSubject({});

// composant.ts

import { context$ } from 'context';

context$.subscribe(context => {
    // ...
});

context$.next({ userId: '0123456789' })

Ici j'ai mis en place une première solution très naïve mais qui respecte l'attendu.

On expose un BehaviorSubject, qui est une implémentation particulière du Subject, lui-même une extension de l'Observable. Pour faire court un Subject est à la fois Observable et Observer, ce qui permet à un Subject à la fois d'être manipulé comme un Observable (donc faire un .pipe() ou .subscribe()) pour obtenir des valeurs, mais aussi comme un Observer pour émettre des valeurs via la méthode .next() par exemple. Le BehaviorSubject en particulier a en plus deux caractéristiques : il possède un état initial, il va ré-émettre à la souscription la dernière valeur qu'il a reçue.

Beaucoup de gens proposeraient d'utiliser un ReplaySubject avec une rétention à 1 (new ReplaySubject(1)), mais pour commencer ça oblige à pousser dans le Subject la valeur initiale après coup, ensuite un ReplaySubject gère différemment les erreurs (mais ce n'est pas important ici).

On peut remarquer que j'ai initialisé le BehaviorSubject avec un objet vide, sans indiquer aucun type. C'est techniquement valide, mais ce n'est pas une bonne habitude, en effet dans ce cas-là ça revient à indiquer que le contenu du BehaviorSubject est any ce qui n'est pas ce qu'on veut. En effet on préférera avoir un type clair dès le début pour éviter de chercher ce qu'on attend comme valeur.

On aurait souvent vu null en valeur initiale, ce qui est à mon sens une mauvaise idée car ça ajoute des validations de nullité à faire pour les traitements. Plus généralement, au fil de l'exercice on va limiter au maximum la possibilité d'avoir des nulls ou undefined.

Pour finir, il faut bien prendre en compte qu'on expose directement notre BehaviorSubject. Ça a l'avantage d'être simple à mettre en place, mais aussi l'inconvénient d'exposer l'implémentation et donc moins contrôler les modifications du contexte et limité les possibilités de faire des refacto à la suite.

2. Façade

Nouvelle version en faisant une implémentation s'inspirant du design pattern Façade.

// context.ts
import { BehaviorSubject } from 'rxjs';

const _context$ = new BehaviorSubject({});
export const context$ = _context$.asObservable();

export function setContext(newContext: any): void {
    _context$.next(newContext);
}

// composant.ts

import { context$, setContext } from 'context';

context$.subscribe(context => {
    // ...
});

setContext({ userId: '0123456789' })

J'ai changé le minimum de code pour protéger le BehaviorSubject à l'intérieur du module context, de sorte à ne permettre que de lire sa valeur. Pour contrôler l'édition, j'ai ajouté une fonction permettant d'éditer la valeur en faisant l'appel à .next() à l'intérieur du module.

Ici on obtient un canal pour la lecture, un pour l'écriture. Depuis le module, on sait quand on écrit et quand on lit le contexte. On peut changer le comportement interne sans changer les appels pour les clients.

3. Un peu de type ?

// context.ts
import { BehaviorSubject } from 'rxjs';

interface Context {
    userId?: string;
    userName?: string;
}

const _context$ = new BehaviorSubject<Context>({});
export const context$ = _context$.asObservable();

export function setContext(newContext: Context): void {
    _context$.next(newContext);
}

// composant.ts

import { context$, setContext } from 'context';

context$.subscribe(context => {
    // ...
});

setContext({ userId: '0123456789', userName: 'john.doe' });
// setContext({ userId: '9876543210' }) => on perdra le username

En ajoutant une interface pour le type, on est déjà mieux que ce qu'on avait en termes de suivi de ce que contient le contexte, mais on aperçoit un nouveau problème : on ne peut pas modifier juste une partie de notre contexte. Parfois ce n'est pas gênant, mais on voudra parfois pouvoir modifier uniquement une seule clé, ce qui n'est pas possible ici, à moins de souscrire pour chaque édition à l'état pour ensuite éditer l'objet, l'éditer et enfin le réinjecter dans le contexte.

On peut aussi mettre en place une fonction de patch.

4. Un patch ?

// context.ts
import { BehaviorSubject, first } from 'rxjs';

interface Context {
    userId?: string;
    userName?: string;
}

const _context$ = new BehaviorSubject<Context>({});
export const context$ = _context$.asObservable();

export function setContext(newContext: Context): void {
    _context$.next(newContext);
}

export function patchContext(contextPatch: Partial<Context>): void {
    _context$
    .pipe(first())
    .subscribe(actual => {
        setContext({ ...actual, ...contextPatch });
    });
}

// composant.ts

import { context$, setContext, patchContext } from 'context';

context$.subscribe(context => {
    // ...
});

setContext({ userId: '0123456789', userName: 'john.doe' });
patchContext({ userId: '9876543210' });

Ici j'ai écrit une version minimale d'une fonction patchContext, qui va récupérer la valeur courante du contexte pour appliquer le patch via de la destructuration. Pour notre exemple c'est amplement suffisant, mais si on avait un objet plus profond (ie. si une des clés de l'objet contexte était lui-même un objet avec différentes clés, qui elles-mêmes pourraient être des objets, etc.), ça obligerait à écrire un algorithme pour appliquer le patch récursivement (d'un point de vue typage c'est assez compliqué à faire proprement).

5. Patch avec une bibliothèque externe

Dans le cas où on aurait besoin d'un patch un peu plus intelligent à écrire, je vous conseille de directement passer par une bibliothèque comme immer. C'est une bibliothèque qui va vous permettre d'éditer plus facilement des objets profonds, conserver correctement les typages, garantir l'immutabilité (on ne modifie pas l'objet mais un proxy qui permettra d'appliquer les changements sur une copie de l'objet).

// context.ts
import { produce } from 'immer';
import { BehaviorSubject, first } from 'rxjs';

interface Context {
    userId?: string;
    userName?: string;
}

const _context$ = new BehaviorSubject<Context>({});
export const context$ = _context$.asObservable();

export function setContext(newContext: Context): void {
    _context$.next(newContext);
}

export function patchContext(contextPatcher: (context: Context) => void): void {
    _context$
    .pipe(first())
    .subscribe(actual => {
        setContext(produce(actual, contextPatcher));
    });
}

// composant.ts

import { context$, setContext, patchContext } from 'context';

context$.subscribe(context => {
    // ...
});

setContext({ userId: '0123456789', userName: 'john.doe' });
patchContext(context => context.userId '9876543210');

Si on ne veut pas exposer la possibilité d'injecter une fonction aussi permissive en édition, on pourra toujours créer des fonctions spécifiques à chaque édition

// context.ts
...

function patchContext(contextPatcher: (context: Context) => void): void {
    _context$
    .pipe(first())
    .subscribe(actual => {
        setContext(produce(actual, contextPatcher));
    });
}

export function setUserId(userId: string): void {
    patchContext(context => {
        context.userId = userId;
    });
}

export function setUserName(userName: string): void {
    patchContext(context => {
        context.userName = userName;
    });
}

// composant.ts

setContext({ userId: '0123456789', userName: 'john.doe' });
setUserId('9876543210');

Dans l'état actuel, on peut faire un peu toutes les modifications qu'on souhaite sur notre contexte, même si notre implémentation risque de ne pas toujours être très pratique à la longue, de plus on est toujours obligé de récupérer l'état actuel avant chaque édition partielle (patchContext) ce qui n'est pas très performant.

Si vous avez déjà utilisé une bibliothèque de gestion d'état comme Redux, vous avez dû vous rendre compte qu'on est assez proche d'un store avec des actions (setUserId, setUserName), donc pourquoi ne pas écrire notre contexte directement sous la forme d'un store réactif ?

6. Écrire un mini store

// context.ts
import { produce } from 'immer';
import { BehaviorSubject, scan } from 'rxjs';

interface Context {
  userId?: string;
  userName?: string;
}

type Action = (context: Context) => Context | void;

const _context$ = new BehaviorSubject<Action>(() => {});

export const context$ = _context$.pipe(
  scan((state, patcher) => produce(state, patcher))
);

export function setContext(newContext: Context): void {
  _context$.next(() => newContext);
}

export function setUserId(userId: string): void {
  _context$.next((context) => {
    context.userId = userId;
  });
}

export function setUserName(userName: string): void {
  _context$.next((context) => {
    context.userName = userName;
  });
}

// composant.ts

import { context$, setContext, setUserId } from 'context';

context$.subscribe(context => {
    // ...
});

setContext({ userId: '0123456789', userName: 'john.doe' });
setUserId('9876543210');

Dans un premier temps j'ai cherché à écrire quelque chose d'assez minimaliste via un usage assez brut de l'opérateur scan. On retrouve bien la logique d'un store, sauf qu'au lieu d'avoir des actions sous forme d'objet avec une clé type, etc. on a directement une action sous forme de fonction qui vient modifier l'état.

Cette méthode fonctionne, mais elle couple fortement la demander de changement et le changement en lui-même (c'est l'action elle-même qui va appliquer un changement). Dans le même temps l'écriture des fonctions n'est pas forcément très pratique et un peu verbeuse.

On pourrait améliorer un peu l'implémentation en passant par des actions sous forme d'objet comme pour Redux et consort.

7. Avec des vraies actions et un vrai reducer ?

// context.ts
import { produce } from 'immer';
import { BehaviorSubject, scan } from 'rxjs';

interface Context {
  userId?: string;
  userName?: string;
}

type Action<ActionType> = { type: ActionType };
type InitAction = Action<'context.INIT'> ;
type SetContextAction = Action<'context.setContext'> & { newContext: Context };
type SetUserIdAction = Action<'context.setUserId'> & { userId: string };
type SetUserNameAction = Action<'context.setUserName'> & { userName: string };

type ContextAction = InitAction | SetContextAction | SetUserIdAction | SetUserNameAction;

const actions$ = new BehaviorSubject<ContextAction>({ type: 'context.INIT' });
const initialState: Context = {};


function reducer(state: Context, action: ContextAction): Context {
  switch (action.type) {
    case 'context.setContext':
      return {
        userId: action.newContext.userId,
        userName: action.newContext.userName,
      };
    case 'context.setUserId':
      return produce(state, (draft) => {
        draft.userId = action.userId;
      });
    case 'context.setUserName':
      return produce(state, (draft) => {
        draft.userName = action.userName;
      });
    default:
      return state;
  }
}

export const context$ = actions$.pipe(scan(reducer, initialState));

export function setContext(newContext: Context): void {
  actions$.next({ type: 'context.setContext', newContext });
}

export function setUserId(userId: string): void {
  actions$.next({ type: 'context.setUserId', userId });
}

export function setUserName(userName: string): void {
  actions$.next({ type: 'context.setUserName', userName });
}


// composant.ts

import { context$, setContext, setUserId } from 'context';

context$.subscribe(context => {
    // ...
});

setContext({ userId: '0123456789', userName: 'john.doe' });
setUserId('9876543210');

Ici j'ai fait pas mal de changement dans la structure du code, mais on est encore un peu plus proche de la structure d'un store à la mode Redux. On a comme précédemment une fonction pour chaque action qu'il est possible d'appliquer sur notre contexte, mais ici on a un objet qui décrit notre action et non plus une fonction.

La gestion du scan a un petit peu changé aussi, tout n'est plus fait dans l'opérateur directement, mais via une fonction que j'ai nommée reducer, dans laquelle on retrouve un switch avec un case pour chaque action qu'on va chercher à réduire. Comme j'ai conservé immer, j'édite un draft de l'état pour les actions de type patch et je renvoie un nouvel objet directement pour le setContext.

Bien qu'assez proche d'un Redux, on a encore pas mal d'élément interne plus ou moins exposé (on expose un observable typiquement). On se retrouve aussi à avoir un ensemble de fonction pour déclencher des actions, et il faut importer chaque action individuellement sans forcément un lien direct entre l'application de l'effet et notre état-observable.

Une solution serait de changer un peu l'API de notre context pour avoir un objet plus proche d'une instance de Redux par exemple.

8. Une vraie API de store

// context.ts
import { produce } from 'immer';
import { BehaviorSubject, Observable, scan } from 'rxjs';
import { first, map } from 'rxjs/operators';

interface Context {
  userId?: string;
  userName?: string;
}

export function setContext(newContext: Context) {
  return { type: 'context.setContext', newContext } as const;
}

export function setUserId(userId: string) {
  return { type: 'context.setUserId', userId } as const;
}

export function setUserName(userName: string) {
  return { type: 'context.setUserName', userName } as const;
}

type InitAction = { type: 'context.INIT' };
type SetContextAction = ReturnType<typeof setContext>;
type SetUserIdAction = ReturnType<typeof setUserId>;
type SetUserNameAction = ReturnType<typeof setUserName>;

type ContextAction =
  | InitAction
  | SetContextAction
  | SetUserIdAction
  | SetUserNameAction;

const actions$ = new BehaviorSubject<ContextAction>({ type: 'context.INIT' });
const initialState: Context = {};

function reducer(state: Context, action: ContextAction): Context {
  switch (action.type) {
    case 'context.setContext':
      return {
        userId: action.newContext.userId,
        userName: action.newContext.userName,
      };
    case 'context.setUserId':
      return produce(state, (draft) => {
        draft.userId = action.userId;
      });
    case 'context.setUserName':
      return produce(state, (draft) => {
        draft.userName = action.userName;
      });
    default:
      return state;
  }
}

const state$ = actions$.pipe(scan(reducer, initialState));

export const contextStore = {
  get(): Observable<Context> {
    return state$;
  },
  select<T>(selector: (context: Context) => T): Observable<T> {
    return state$.pipe(map(selector));
  },
  selectOnce<T>(selector: (context: Context) => T): Observable<T> {
    return state$.pipe(map(selector), first());
  },
  dispatch(action: ContextAction): void {
    actions$.next(action);
  },
} as const;

// composant.ts

import { contextStore, setContext, setUserId } from 'context';

contextStore.get().subscribe(context => {
    // ...
});

contextStore.dispatch(setContext({ userId: '0123456789', userName: 'john.doe' }));
contextStore.dispatch(setUserId('9876543210'));

Là on a vraiment un store au sens où on l'entend habituellement, avec des actions clairement définies. J'en ai profité pour montrer qu'on peut facilement exposer aussi bien un moyen pour récupérer l'état complet (get()), mais aussi une sous partie de l'état (select(selector)), ainsi qu'une sous partie l'état mais une seule fois (selectOnce(selector)). Je n'ai pas poussé le vice jusqu'à définir un getOnce(), mais il n'y a qu'un pas pour l'avoir.

On arrive avec un système de gestion d'état assez générique, on a une API très proche de Redux et ses consors, on arrive à un point où se pose forcément la question de basculer sur un outil de gestion d'état du marché. Car au fond pourquoi réinventer la roue ?

9. Et pourquoi pas Elf ?

// context.ts
import { Store, createState, withProps, setProp } from '@ngneat/elf';

interface Context {
  userId?: string;
  userName?: string;
}

const { state, config } = createState(withProps<Context>({}));

export const contextStore = new Store({ state, name: 'auth', config });

export function setContext(newContext: Context) {
  return () => newContext;
}

export function setUserId(userId: string) {
  return setProp('userId', userId);
}

export function setUserName(userName: string) {
  return setProp('userName', userName);
}

// composant.ts

import { contextStore, setContext, setUserId } from 'context';

contextStore.subscribe(context => {
    // ...
});

contextStore.update(setContext({ userId: '0123456789', userName: 'john.doe' }));
contextStore.update(setUserId('9876543210'));

Ici j'ai fait l'exercice avec Elf (une bibliothèque de gestion d'état proposé par ngneat et qui n'est adossé à aucun framework, uniquement lié à RxJS). J'aurai pu choisir une autre bibliothèque, mais j'aime bien l'approche et la simplicité d'Elf. Tout en étant assez proche de ce que je vous proposais d'écrire jusque-là.

On voit qu'en utilisant une solution sur étagère, on gagne très fortement en concision sur le code (et encore on pourrait aller encore plus loin dans l'utilisation d'Elf et économiser encore un peu de code). On y gagne aussi sur d'autres points que je n'avais pas abordé jusque-là : l'observabilité d'un point de vue humain. Clairement la solution qu'on a développé tout au long de l'article était fonctionnelle mais ne permettait pas de voir facilement le contenu de l'état, les changements de celui ci, etc. Là avec un outil comme Elf, on a directement accès à des outils développeurs qui permettent de lire le contenu de l'état, voir les actions passer, etc.

Conclusion

Je pourrais retravailler ce genre de cas pendant des jours. On a beau être face à quelque chose de très simple, on peut pousser la solution très loin !

D'après vous j'ai choisi quelle solution pour mon projet ?

Je me suis arrêté à l'étape 3, en effet tout ce qui vient ensuite est intéressant pour beaucoup de raisons, mais dans la pratique je n'avais pour l'instant que 2 chaînes de caractères à partager. Ces deux chaînes vont à priori changer toujours au même moment car elles sont fonctionnellement liées.

Ce qu'il faut retenir c'est que parfois on peut faire des choses assez puissantes, assez facilement. On a pas toujours besoin de sortir des grosses solutions qui viennent du marché, on peut recoder en quelques lignes la même chose. À l'inverse, parfois c'est clairement mieux d'utiliser une solution toute prête, on gagne du temps, le code est plus clair et déjà très bien testé.

Vous vous en doutez, je ne peux que vous recommander de vous poser quelques minutes si vous arrivez à des cas comme ça pour choisir la bonne solution pour votre usage !

Crédit photo : https://pixabay.com/photos/red-matrix-matrix-matrix-code-5031496/