Saltar a contenido

Redux

Para la utilización de redux en todos los proyectos modernos se recomienda utilizar redux toolkit que contiene justamente el estándar de utilidades para reducir el boilerplate tan criticado de Redux. (creadores de accion, acciones, reducers, etc)

¿Qué usar y que no?

Consideramos importante no acoplarnos tanto al manejador de estado global por lo tanto consideramos apropiado no utilizar rtk query, para no acoplar la capa de servicios al manejador de estado. Aunque en lo habitual Redux se utiliza cómo una especie de cache.

Estructura inicial

Se recomienda separar la aplicación por entidades, (usuario, pedidos, servicios), por lo que estás entidades deberían tener su respectivo slice (porción del store global). Recomendamos está estructura base inicial.

store/features/person.ts
import type { IState } from "@architecture-it/core";
import type { IPerson } from "interfaces";
import type { RootState } from "../store"

interface IPersonState extends IState<IPerson[]> {}

const initialState: IPersonState = {
    data: [],
    isLoading: false,
    error: null
}

const personSlice = createSlice({
  name: "person",
  initialState,
  reducers: {},
});
// Si tuviesemos acciones simples (funciones en reducers)
// export const { } = personSlice.actions;

//Importante el selectore de la porción del store, que sería el estado de personas
export const selectPerson = (state: RootState) => state.person;

//Se exporta por default el reducer
export default personSlice.reducer;
store/reducers.ts
import { combineReducers } from "@reduxjs/toolkit";
import personReducer from "./features/person";

const rootReducer = combineReducers({
    person: personReducer
});

export default rootReducer;

Evitemos al máximo colocar todo en el root del store

Es una mala práctica volcar todo sobre un único reducer, lo que se recomienda es separarlo mínimamente por entidades, que complementa muy bien las API Rest que en Andreani son estándar.

No hacer Preferir la subdivisión

// NO HACER
export const store = configureStore({
    reducer: {
        personas: [],
        pedidos: [],
        isLoading: false
    },
})

Uso con Typescript

Para tener el autcompletado del estado se recomienda tener los tipos tanto del rootState, dispatch como del selector.

store/index.ts
import { configureStore } from "@reduxjs/toolkit";
import rootReducer from "./reducers";

export const store = configureStore({
  reducer: rootReducer,
})

// Infer the `RootState` and `AppDispatch` types from the store itself
export type RootState = ReturnType<typeof store.getState>
// Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState}
export type AppDispatch = typeof store.dispatch

Utilizar los hooks tipados centralizados para evitar tener que enviar siempre la interfaz que utiliza. Nos permite cierto grado de abstracción para llamar otro manejador de estado a futuro si quisieramos cambiarlo.

store/hooks.ts
import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux";
import type { RootState, AppDispatch } from "./";


// Utilizar el dispatch y el sector tipado en vez del plano `useDispatch` y `useSelector`
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

getState con tipos

Para obtener el tipo de getState sin tener que castearlo en typescript podemos hacer lo siguiente, agregar el siguiente archivo

store/reduxAugmentation.ts
import type { AsyncThunk, AsyncThunkOptions, AsyncThunkPayloadCreator } from "@reduxjs/toolkit";
import type { Dispatch } from "redux";

import type { RootState } from "./store";

declare module "@reduxjs/toolkit" {
  type AsyncThunkConfig = {
    state?: unknown;
    dispatch?: Dispatch;
    extra?: unknown;
    rejectValue?: unknown;
    serializedErrorType?: unknown;
  };

  export function createAsyncThunk<
    Returned,
    ThunkArg = void,
    ThunkApiConfig extends AsyncThunkConfig = { state: RootState } // here is the magic line
  >(
    _typePrefix: string,
    _payloadCreator: AsyncThunkPayloadCreator<Returned, ThunkArg, ThunkApiConfig>,
    _options?: AsyncThunkOptions<ThunkArg, ThunkApiConfig>
  ): AsyncThunk<Returned, ThunkArg, ThunkApiConfig>;
}

Separar la lógica de negocio de la UI.

Es habitual utilizar redux cómo cache, (aunque esto es discutible con herramientas cómo react query, u otras). Pero en este caso no es objeto de discusión si no demostrar cuales serían las mejores prácticas en el caso de utilizarlo cómo cache o necesitar acciones asíncronas.

Acciones asíncronas

Para utilizar las acciónes asíncronas Redux toolkit ya contiene Thunks especialmente util y menos verboso que Sagas.

Dentro de las acciones asíncronas tenemos acceso al dispatch y al getState en la versión 'vainilla', pero en toolkit nos provee de más herramientas útiles que veremos en la siguiente sección.

La intención con las acciones asíncronas es evitar colocar lógica de más en la ui, separando la lógica de pedir la información y sólo escuchar si el estado efectivamente la tiene, veamos un ejemplo

store/features/person/asyncActions.ts
import type { IPerson } from "interfaces";
import PersonService from "services/person";

export const getAll = createAsyncThunk<IPerson[], void>(
  "person/getAll",
  async (_, { rejectWithValue, signal }) => {
    try {

      // signal es justamente de tipo AbortSignal, permite cancelar la promesa, programaticamente, y por lo tanto la llamada al servicio
      // se llama desde services/person/index.ts
      const data = await PersonService.getAll(idOrder, signal);

      return data;
    } catch (err) {
      // función que permite cancelar la promesa con el error que corresponda
      return rejectWithValue(err.isAxiosError ? err.response.data : err);
    }
  }
);

Para que está acción asíncrona funcione correctamente hay que definir los casos dónde se está ejecutando, se ejecutó correctamente o falló, para lo mismo lo vamos a configurar en el slice, en la sección de extra reducers. Está sección por debajo utilizá immer por lo que nos permite una sintaxis más sencilla.

Las promesas por default tienen tres estados posibles: pending, fulfilled o rejected, y en función a eso podemos ejecutar acciones puntuales sobre el estado.

store/features/person.ts
// omitido por brevedad

import { getAll } from "./asyncActions";

// omitido por brevedad

const personSlice = createSlice({
  name: "person",
  initialState,
  reducers: {},
  extraReducers: (builder) => {
    // getAll
    builder.addCase(getAll.pending, (state) => {
      state.error = null;
      state.isLoading = true;
    });
    builder.addCase(getAll.rejected, (state, action) => {  
      if (action.payload) {
          state.error = action.payload as string;
      } else {
          state.error = action.error?.message as string;
      }
      state.isLoading = false;
    });
    builder.addCase(getAll.fulfilled, (state, { payload }) => {
      // payload es de tipo IPerson[]
      state.data = payload;
      state.error = null;
      state.isLoading = false;
    });
  }
});
// Omitido por brevedad

Por lo que en la ui sólo necesitaríamos escuchar el slice correspondiente (Ejemplo básico)

pages/people.tsx
import React from "react";
import { useAppDispatch, useAppSelector } from "store/hooks";
import { selectPerson } from "store/features/person";
import { getAll } from "store/features/person/asyncActions";
import Skeleton from "@mui/material/Skeleton";

export default function PeoplePage(){
    const dispatch = useAppDispatch();
    const { data: people, isLoading } = useAppSelector(selectPerson);

    React.useEffect(()=>{
        dispatch(getAll());
    },[dispatch])

    // Si está cargando mostramos un skeleton o un loader.
    if (isLoading){
        return <Skeleton />;
    }

    return (
        <ul>
            {
                people.map((person, i) => (<li>Name: {person.name}</li>))
            }
        </ul>
    )
}

Casos Avanzados

Cancelar antes de la ejecución (Condición)

Hay casos dónde si estámos cacheando podemos establecer cuando pedir la información, así evitar que se llame continuamente si depende de cierto renderizado que se ejecuta continuamente.

store/features/person/asyncActions.ts
import type { IPerson } from "interfaces";
import PersonService from "services/person";

export const getAll = createAsyncThunk<IPerson[], void>(
  "person/getAll",
  async (_, { rejectWithValue, signal }) => {
    try {
      // se llama desde services/person/index.ts
      const data = await PersonService.getAll(idOrder, signal);

      return data;
    } catch (err) {
      // función que permite cancelar la promesa con el error que corresponda
      return rejectWithValue(err.isAxiosError ? err.response.data : err);
    }
  }, 
  {
    condition: (_, { getState }) => {
        const { person } = getState(); //getState devuelve RootState

        // Siempre y cuando no esté cargando.
        // Podría agregarse cualquier lógica necesaria
        return !person.isLoading;
    }
  }
);

Cancelar durante de la ejecución

Si por algún motivo el rerender hace que se pida la información podemos evitarlo o cancelarlo por medio del la acción que se ejecuta al desmontar el componente.

pages/people.tsx
//...omitido por brevedad 

export default function PeoplePage(){
    const dispatch = useAppDispatch();
    const { data: people, isLoading } = useAppSelector(selectPerson);

    React.useEffect(()=>{
        const promise = dispatch(getAll());

        return () => {
            promise.abort();
        }
    },[dispatch]);

    //...omitido por brevedad 
}