Resolviendo side-effects en React

Publicado por Raúl Yeguas el

ReactReduxFront

El uso de side-effects en nuestro estado de React es un problema resuelto con demasiadas soluciones; y aquí queremos ayudarte a encontrar la que mejor se ajuste para tu proyecto.

¿A donde queremos ir?

En este artículo vamos a enfrentar varias librerías de gestión de efectos de estados en React para resolver la misma aplicación y así encontrar sus pros y contras a la hora de aplicarlos en proyectos en los que se necesitan principalmente las siguientes características:

  • Efectos colaterales de peticiones de red
  • Comunicación entre distintos slices / estados
  • Gestión de errores

¿Cómo pinta el paisaje?

Para darle un enfoque pragmático, se han tenido en cuenta las que se basan en la librería más usada actualmente con React, Redux; además de añadir un interesante invitado extra. Así la lista de contendientes es:

En los casos con Redux se ha utilizado Redux-Toolkit como base para simplificar el código por slices y aprovechar atajos como Immer.

¿Cómo descubrir la verdad?

La aplicación que todas las librerías implementarán es Dogstagram, una aplicación ficticia para ver fotografías de distintas razas de perros seleccionando éstas de una lista. Se trata de una aplicación muy sencilla pero que cumple con los requerimientos a testear de cada una de ellas.

En esta aplicación tenemos, por un lado, un estado para gestionar los datos de razas de perros y las fotografías de la raza seleccionada, así como flags para saber si estamos cargando datos; y por otro, el estado de gestión de mensajes al usuario que serán de información o de error, cada uno con un comportamiento distinto.

Para añadir un poco más de pimienta, se ha optado por usar TypeScript en todas las implementaciones.

El despreocupado Thunk

Comenzamos con Redux-thunk que tiene la ventaja de que viene por defecto con Redux-Toolkit. En este caso vemos que para gestionar efectos colaterales necesitamos crear thunks que se comportan como reducers que llaman a otros reducers y que de hecho podemos mantener en el mismo fichero que el resto. Por ejemplo tenemos fetchBreedList que será llamado desde el componente y se implementa así:

export const fetchBreedList = (): AppThunk => async (dispatch) => {  
  try {
    dispatch(fetchBreedListStart());         // 1. Despachamos la acción carga inicial
                                             //    para el estado de datos
    dispatch(clearUserMessage());            // 2. Limpiamos los mensajes del estado 
                                             //    de mensajes
    const breeds = await getBreedList();     // 3. Obtenemos los datos desde una 
                                             //    petición a un servicio
    dispatch(fetchBreedListSuccess(breeds)); // 4. Si todo OK, despachamos la acción
                                             //    de éxito de carga de datos
  } catch (e) {
    dispatch(fetchBreedListFailure());       // 5. Si hay algún error, despachamos la
      const message: UserMessage = {         //    acción de error al obtener datos y
        type: UserMessageType.error,         //    mandamos un mensaje al usuario
        message: e.message
      };
    dispatch(setUserMessage(message));
  }
};

Su tipado no es complejo, sólo necesitamos crear un tipo AppThunk basado en el estado raíz y usarlo cuando implementemos un thunk:

// Creamos el reducer raíz
const rootReducer = combineReducers({  
  breeds: breedReducer,
  userMessage: userMessageReducer
});

// Creamos un tipo para el reducer raíz
export type RootState = ReturnType<typeof rootReducer>;

// Aprovechamos ese tipo para crear el tipo de las acciones Thunk de nuestra app
export type AppThunk = ThunkAction<void, RootState, null, Action<string>>;  

Ventajas 👍

  • Rápido de implementar
  • Sigue la misma lógica de las acciones
  • Fácil tipado

Inconvenientes 🙅

  • Confunde el hecho de tener acciones de reducers definidas en dos lugares distintos
  • Puede incitar a hacer el mal (el mal uso de los reducers)
  • En caso de tener que despachar una acción de otro slice, crea un pequeño acoplamiento

Saga el sibarita

Redux Saga se basa en generadores para crear efectos asíncronos y, al contrario de Thunk pero al igual que el resto, intercepta los dispatch de las acciones para lanzarlos, por lo que se recomienda implementar en un fichero distinto. Para trabajar con sagas por un lado definiremos la saga:

function* fetchBreedListSaga() {  
  try {
    const breeds: Breed[] = yield call(getBreedList); // 1. Al ejecutar la saga, llamamos a 
                                                      //    nuestro servicio
    yield put(fetchBreedListSuccess(breeds));         // 2. Si todo OK, despachamos la acción de 
                                                      //    éxito de carga de datos
  } catch (e) {
    const message: UserMessage = {
      type: UserMessageType.error,
      message: e.message
    };
    yield all([                                       // 3. Si hay algún error, despachamos la
      put(fetchBreedListFailure()),                   //    acción de error al obtener datos y
      put(setUserMessage(message))                    //    mandamos un mensaje al usuario
    ]);
  }
}

Vemos como aquí usamos yield del generador para realizar una ejecución asíncrona de los servicios, junto con las funciones call para llamar servicios externos o put para despachar acciones. También permite lanzar varios elementos a la vez usando all.

Por otro lado, definiremos las acciones que interceptaremos y con qué sagas lo haremos:

function* breedSaga() {  
  yield takeLatest(fetchBreedList, fetchBreedListSaga);
}

Tenemos varias opciones para interceptarlas, takeLatest en este caso ejecuta una saga cada vez que se llama a la acción, pero cancela las anteriores en caso de existir y evitar errores de condición de carrera; pero existen otras formas de enlazar los efectos como take, takeLeading o takeEvery entre otros, que ofrecen otros comportamientos.

En cuanto al tipado, no requiere nada en especial y hereda lo necesario de las acciones y los servicios.

Ventajas 👍

  • Más limpio que Thunk
  • Buen equilibrio entre flexibilidad y facilidad de uso
  • Fácil tipado
  • El uso de generadores tiene su punto chic 🎩
  • Bonus: funciona con Redux-Injectors que te permite inyectar las sagas (y los reducers) sólo cuando es necesario

Inconvenientes 🙅

  • El uso de generadores puede ser extraño para desarrolladores menos experimentados
  • Si olvidamos usar yield en alguna llamada sufriremos esos errores tontos que a veces cuesta ver y que te traen de cabeza.

El enigmático Observable

Redux Observable se presenta como una alternativa para los usuarios de RxJS, con una mayor potencia pero también una mayor dificultad para desarrolladores ajenos a esta librería. Se basa en observables de RxJS que tienen acciones como entrada y acciones como salida. Veamos cómo definir el epic de nuestro caso de muestra:

const fetchBreedListEpic: EpicType = (action$, store, { getBreedList }) =>  
  action$.pipe(                            // El observable action$ recibe todas las acciones
                                           // despachadas
    filter(fetchBreedList.match),          // 1. Filtramos las acciones para obtener sólo 
                                           //    las que nos interesan
    switchMap(action => {                  // 2. Cogemos siempre el último valor para trabajar
      return from(getBreedList()).pipe(    // 3. Creamos un observable basado en el servicio
        map(fetchBreedListSuccess),        // 4. Si todo OK, aplicamos el valor a la acción de 
                                           //    éxito de carga de datos
        catchError((e) => {
          const message: UserMessage = {
            type: UserMessageType.error,
            message: e.message
          };
          return of(                       // 5. Si hay algún error, emitimos las acciones de error
            fetchBreedListFailure(),       //     al obtener datos y de emisión de mensajes
            setUserMessage(message)        //     al usuario con el contenido del error
          );
        })
      );
    })
  );

Al definir un epic (o una épica) creamos una cadena de procesos en la que filtraremos las acciones que nos interesen para trabajar con ellas a continuación. En su definición hay que pasar las acciones, la store y los servicios que usemos como dependencias.

Posteriormente exportaremos las épicas creadas pudiendo combinarlas con combineEpics y tiparlas para funcionar con TypeScript, en cuyo caso necesitaremos tipar las acciones de entrada, las acciones de salida, la store y las dependencias. En nuestro caso lo hemos resuelto en los ficheros index.ts de cada sección aunque en conjunto quedaría algo así:

/* Agrupamos las acciones y los servicios API */
import * as API from 'api/dogApi';  
const actions = {  
  ...breedActions,
  ...userMessageActions
}

// Creamos los tipos para cada uno
export type ActionsType = ActionType<typeof actions>;  
export type RootState = ReturnType<typeof rootReducer>;  
export type APIType = typeof API;

// Creamos el tipo del Epic usando los creados anteriormente
export type EpicType = Epic<ActionsType, ActionsType, RootState, APIType>;  

Ventajas 👍

  • Toda la potencia y flexibilidad de RxJS para controlar y definir tu epic de la forma que necesites
  • Fácil curva de entrada para usuarios de esta librería (y posiblemente la opción preferida de devs de Angular)

Inconvenientes 🙅

  • Puede ser complejo para devs que no conozcan los observables
  • Requiere de más trabajo de tipado

Logic el conciliador

Logic nace con la idea de aprovechar la potencia de RxJS inspirándose en la sencillez de otros middlewares. Gracias a su flexibilidad y a estar basado en RxJS te permite escribir tu lógica usando callbacks, promesas, llamadas asíncronas o usar observables directamente, definiendo su funcionamiento de forma declarativa mediante propiedades y ofreciendo 3 hooks para distintas fases de ejecución: validate, transform y process. Con todo esto, nuestra lógica se vería así:

const fetchBreedListLogic = createLogic({  
  type: fetchBreedList,                         // 1. Definimos la acción a interceptar
  latest: true,                                 // 2. Indicamos que queremos usar sólo 
                                                //    la última llamada
  async process(depObj, dispatch, done) {       // 3. Definimos el "procesamiento" a aplicar
                                                //    a las llamadas capturadas
    try {
      const breeds = await getBreedList();      // 4. Obtenemos las razas de nuestro servicio
      dispatch(fetchBreedListSuccess(breeds));  // 5. Si todo OK, despachamos la acción de 
                                                //    éxito de carga de datos
    } catch (e) {
      const message: UserMessage = {
        type: UserMessageType.error,
        message: e.message
      };
      dispatch(fetchBreedListFailure());        // 6. Si hay algún error, despachamos la 
      dispatch(setUserMessage(message));        //    acción de error al obtener datos y
                                                //    mandamos un mensaje al usuario
    }
    done();                                     // 7. Indicamos que hemos terminado
  }
});

Si prestamos atención al código vemos que tenemos la potencia de los Observables, la limpieza de una Saga y facilidad de uso de un Thunk; y su carga en la store es igual a cualquier otro middleware.

En cuanto al tipado, de forma análoga a Sagas, no requiere nada en especial e infiere lo necesario de las acciones y los servicios.

Ventajas 👍

  • Potente y flexible a la vez que sencillo de aprender
  • Realmente parece coger lo mejor de cada middleware
  • Sus 3 hooks permiten interceptar de forma fácil las acciones antes y después de su despacho, para poder aplicar validaciones o transformar datos si fuera necesario
  • Permite cancelaciones mediante intercepción de acciones
  • Permite interceptar varias acciones distintas mediante expresiones regulares, definiéndolas en un array, por texto o usando wildcard para interceptar todas
  • Puedes escribir tu lógica usando RxJS de forma directa si lo deseas
  • Fácil tipado

Inconvenientes 🙅

  • No es tan famoso como el resto de opciones (1.8k estrellas en GitHub frente a 16.3k, 21.6k o 7.6k de sus competidoras directas)
  • Em... se va a notar que es mi opción preferida, pero no le veo más inconvenientes 😅.

El especialista XState

Pero, ¿y si no queremos usar Redux o directamente ninguna arquitectura Flux? Para eso tenemos otras opciones y una de las más llamativas es XState, una máquina de estados disponible para muchos frameworks y librerías, tanto para frontend como backend que permite un control milimétrico de tu estado.

En este caso necesitaremos definir una máquina completa, con sus estados, contexto, transiciones y acciones, que ya incluyen los side-effects. Aunque no sea lo más correcto, para facilitar la comprensión y la comparación entre librerías, reflejaremos los elementos principales de Redux en XState de la siguiente forma:

| Redux        | XState               |
| ------------ | -------------------- |
| State        | State + Context      |
| Actions      | Events               |
| Reducers     | Transitions          |
| Side-effects | Actions + Activities |

Sabiendo esto, y aunque hay mucho más que explicar, veamos cómo quedaría nuestra máquina de estados para nuestro caso base:

export const breedMachine = Machine<BreedContext, BreedStateSchema, BreedEventType>(  
  {
    id: 'breeds',                                 // 1. Definimos el ID de la máquina
    initial: 'idle',                              // 2. Definimos su estado inicial
    context: {                                    // 3. Definimos su contexto inicial
      breedList: [],
      selectedBreed: null,
      breedPhotoList: [],
      errorMsg: null
    },
    states: {                                     // 4. Definimos los distintos estados posibles
      idle: {                                     //    usando nodos de estado o StateNodes
        ...fetchBreedListState                    //    (lo definiremos a continuación)
      },
      selected: {
        ...fetchBreedPhotoListState
      }
    },
    on: {                                         // 5. Definimos las transiciones globales
      [BreedEvent.SELECT]: {
        target: 'selected',
        actions: assign((_, event) => {
          return {
            selectedBreed: event.name,
            breedPhotoList: []
          };
        })
      }
    }
  },
  {
    services: {                                   // 6. Y por último definimos servicios que
      getBreedList: () => getBreedList(),         //    necesitaremos en nuestras acciones
      getBreedPhotoList: (context) => {
        if (!context.selectedBreed) {
          throw new Error('Breed not selected');
        }
        return getBreedPhotoList(context.selectedBreed);
      }
    }
  },
);

Lo primero que salta a la vista es que en este caso la película ha cambiado mucho, pero atendiendo a cada definición, se parece mucho a lo que definimos en Redux, aunque tenemos algo más de control, como validaciones, transiciones condicionales, o un estado general que complementa al contexto. Además, la configuración de cada estado contiene su propia configuración y estado internos que podemos definir aparte. En este caso hemos separado la que corresponde a obtener la lista de razas, que sería la siguiente:

const fetchBreedListState: StateNodeConfig<BreedContext, BreedListStateSchema, BreedEventType> = {

  initial: 'loading',                             // 1. Nada más entrar, pasamos al estado 'loading'
  states: {
    loading: {                                    // 2. El estado loading invoca una acción
      invoke: {
        id: 'fetch-breeds',
        src: 'getBreedList',                      // 3. La acción llama al servicio 'getBreedList'
        onDone: {                                 // 4. Si todo OK, cambia al estado 'loaded' y 
          target: 'loaded',                       //    modifica el contexto para que contenga
          actions: assign({                       //    los datos recibidos
            breedList: (_, event) => event.data
          })
        },
        onError: {                                // 5. En caso de error, pasamos al estado 'failure'
          target: 'failure',                      //    y guardamos la información del error en el
          actions: assign({                       //    contexto
            errorMsg: (_, event) => 
              event.data.message
          })
        }
      }
    },
    loaded: {                                     // 6. Para el estado 'loaded' indicamos que es el
      type: 'final'                               //    estado final y que ahí termina la ejecución
    },
    failure: {                                    // 7. Y en el estado 'failure' nos quedamos a la
      on: {                                       //    espera del evento 'retry' para reintentarlo
        [BreedEvent.RETRY]: 'loading'
      }
    }
  }
};

Con esto, ya tendríamos la misma llamada que en los casos anteriores, pero nos faltaría la comunicación entre esta máquina y la que gestiona los mensajes de usuario. Para conseguirlo hay que superar una limitación del diseño de XState: las máquinas no pueden comunicarse si no forman parte de la misma jerarquía. Para solucionarlo no tenemos ningún método oficial porque depende mucho de tu diseño de máquina, por lo que para este caso, inspirándome en la forma de trabajar de Redux, he creado una máquina raíz que aglutine a todas las máquinas y facilite tanto su instanciación como la comunicación entre ellas, usando la raíz como enrutador de llamadas.

Para eso, usamos una función de utilidad creada manualmente, que actua de forma similar a funciones como combineReducers de Redux, llamada createRootMachine. Esta función devolverá una máquina que tiene como contexto las referencias a las máquinas hijas, permitiendo trabajar con ellas de forma independiente pero también la comunicación entre ellas usando las funciones sendParent y send.

export const rootMachine = createRootMachine<RootMachineContext, RootMachineEventType>({  
  userMessage: userMessageMachine,
  breeds: breedMachine
});

Tras esta ejecución, ambas máquinas son hermanas por lo que podemos enviar mensajes a la máquina raíz usando sendParent, de forma que la gestión de errores ahora quedaría así:

failure: {                                   // 1. Definimos el estado 'failure'  
  entry: sendParent(                         // 2. Nada más entrar, enviamos el mensaje
    (context) => ({                          //    a la máquina padre con el contenido del
      type: UserMessageEvent.SHOW,           //    error
      severity: UserMessageSeverity.ERROR,
      message: context.errorMsg
     })
  ),
  on: {                                      // 3. Posteriormente quedamos a la espera
    [BreedEvent.RETRY]: 'loading'            //    del evento 'retry' para reintentarlo
  }
}

Luego la máquina raíz reenviará el mensaje a la máquina correspondiente, atendiendo a un sistema de espacio de nombres previamente acordado, usando el método send y agregando la propiedad to para definir el destinatario:

// En la definición de la máquina raíz
on: {  
  '*': {                                              // 1. Para cualquier evento
    actions: send(                                    // 2. Enviamos un mensaje a una máquina hija
      (_, event) => event,                            //    con el contenido del evento de entrada
      { to: (_, event) => event.type.split('/')[0] }  //    y direccionado al namespace de la máquina
    )                                                 //    usando el formato `id_máquina/id_evento`
  }                                                   //    en el tipo del evento
}

Ahora el mensaje enviado desde la máquina de razas de perro llegará a la de mensajes de usuario y desencadenará la aparición del mensaje de error en pantalla.

Para finalizar, acerca del tipado, ya en el código de ejemplo se nota que el tipado es muy explícito y se usan mucho los genericos de TypeScript, lo que obliga a crear tipos para el esquema del estado, el contexto, los eventos y las máquinas y nodos que usan estos, por lo que para cada máquina necesitaremos definir más o menos lo siguiente:

export interface BreedStateSchema {  
  states: {
    idle: BreedListStateSchema;
    selected: BreedListStateSchema;
  };
}

interface BreedListStateSchema {  
  states: {
    loading: {};
    loaded: {};
    failure: {};
  }
}

export interface BreedContext {  
  breedList: Breed[],
  selectedBreed?: string,
  breedPhotoList: string[],
  errorMsg?: string
}

export enum BreedEvent {  
  SELECT = 'breeds/select',
  RETRY = 'breeds/retry'
}

export type BreedEventType =  
  | { type: BreedEvent.SELECT; name: string }
  | { type: BreedEvent.RETRY };

export type BreedMachineRefType = ActorRef<BreedEventType>;

const fetchBreedPhotoListState: StateNodeConfig<BreedContext, BreedListStateSchema, BreedEventType> = ...

export const breedMachine = Machine<BreedContext, BreedStateSchema, BreedEventType>({...})  

Y posteriormente, para la máquina raíz:

export interface RootMachineContext {  
  userMessageRef: UserMessageMachineRefType,
  breedsRef: BreedMachineRefType
};

export interface RootMachineSchema {  
  states: { initial: {} }
}

export type RootMachineEventType = UserMessageEventType | BreedEventType;  
export type RootMachineInterpreter = Interpreter<RootMachineContext, RootMachineSchema, RootMachineEventType>;  

Eso sí, una vez definido lo principal, el desarrollo es mucho más cómodo y apenas presenta problemas de tipado.

Ventajas 👍

  • Control milimétrico del estado, sus transiciones, acciones,...
  • Incluir validaciones y efectos colaterales es muy fácil y ayuda a entender la máquina como un todo
  • Permite revisar tu diseño de máquina de estado y depurarla de forma muy sencilla usando su visualizador de máquinas de estado
  • Tienes que crear tu propio contexto de React por cada máquina, por lo que puedes personalizarlo a tus necesidades

Inconvenientes 🙅

  • También por tener que crear tu propio contexto por cada máquina, da algo más de trabajo al principio y obliga a definir una arquitectura por tu cuenta
  • La comunicación entre máquinas no es posible si no forman parte de la misma jerarquía, hay varias soluciones a esto pero no todas se ajustan a cualquier sistema de máquinas
  • Está orientado a proyectos concretos en los que una máquina de estados ofrece una ventaja clara
  • Tipado mucho más duro y exhaustivo
  • Un gran poder conlleva una gran responsabilidad

La fotografía final

En mi humilde opinión, la opción más ventajosa es Redux Logic por traer lo mejor de cada uno de sus competidores, intentando que sea sencillo por defecto y potente cuando hace falta; pero no dejo de lado la opción de XState. A pesar de que este ha sido complejo de usar, me ha encantado su potencia y control para todo y una vez tienes definida una arquitectura, es más fácil de "dibujar" con la mente, muy rápido de trabajar y muy robusto; aunque también teniendo claro que no es una opción para cualquier proyecto.

Todo el código de esta aplicación está disponible en este repositorio de GitHub en distintas ramas para cada implementación de forma que puedas revisarlo y comparar cada uno:

Ahora que tienes toda la información, ¿cuál es la mejor opción para ti? Participa y déjalo en comentarios.

¡Síguenos en Twitter para estar al día de próximas entregas!