Uso del plugin de Redux para capturar cambios de estado

Aprende a capturar los cambios de estado con el plugin de Redux y a visualizarlos en las repeticiones de sesión

Uso del plugin de Redux para capturar cambios de estado

Video Tutorial

Mira cómo rastrear el estado con Redux

Si no te gusta leer, puedes seguir este tutorial en vídeo que te muestra cómo rastrear el estado de tus aplicaciones React cuando usas Redux

Si necesitas más visibilidad al reproducir las sesiones de tus usuarios, poder echar un vistazo al estado de la aplicación puede resultar muy útil.

En el caso de Redux, OpenReplay ofrece un plugin que te permite integrarte en el funcionamiento interno del store. Este plugin te permitirá ver el estado del store de Redux y las acciones despachadas a lo largo de la sesión grabada.

Una vez configurado, deberías poder observar los cambios en el store tal como se ve en la siguiente captura de pantalla:

Resultado esperado

Configurar Redux en un proyecto de Next.js

Section titled Configurar Redux en un proyecto de Next.js

Para este tutorial, usaremos este repositorio (rama redux-store) de un sitio de comercio electrónico genérico construido con Next.js.

En este proyecto, reemplazaremos un conjunto de productos destacados por un conjunto de productos nuevos obtenidos de una API externa.

Para ello, añadiremos una función que solicite los productos usando Axios, y lo haremos desde dentro de una acción de Redux.

Nota: Esta es una aplicación de Next.js compleja, por lo que puede que no siga la estructura estándar que se encuentra en las clásicas aplicaciones de tareas (To-Do), pero siguiendo este tutorial deberías poder seguir el ritmo de los cambios.

Recuerda: siempre puedes clonar el repositorio y revisar el código por ti mismo.

Empezaremos instalando todas las dependencias principales con:

npm i next-redux-wrapper redux react-redux redux-thunk redux-devtools-extension

Una vez hecho esto, crea una carpeta llamada store en el directorio raíz de tu proyecto y reproduce la siguiente estructura:

Estructura de carpetas

El archivo types.js contendrá la definición de tipo para las dos acciones que vamos a definir:

export const GET_PRODUCTS = 'GET_PRODUCTS'
export const PRODUCTS_ERROR = 'PRODUCTS_ERROR'

El archivo store.js exportará una función que, CUANDO se llame, creará un nuevo store de Redux. Esto se debe a que necesitaremos añadir un nuevo middleware de Redux devuelto por el plugin de Redux (más sobre esto en un momento).

import { createStore, applyMiddleware } from 'redux'
import thunk from 'redux-thunk'
import { composeWithDevTools } from 'redux-devtools-extension'

import rootReducer from './reducers'

const initalState = {}

export default function createReduxStore(extraMiddleware = []) {
  const middleware = [thunk, ...extraMiddleware]

  const store = createStore(
    rootReducer,
    initalState,
    composeWithDevTools(applyMiddleware(...middleware))
  )
  return store
}

Nuestro archivo reducer (makeUpReducer.js) actualizará el estado con la lista de productos o con el mensaje de error devuelto cuando haya un problema.

import { GET_PRODUCTS, PRODUCTS_ERROR } from '../types'

const initialState = {
  makeUpProducts: [],
  loading: true,
}

export default function (state = initialState, action) {
  switch (action.type) {
    case GET_PRODUCTS:
      return {
        ...state,
        makeUpProducts: action.payload,
        loading: false,
      }
    case PRODUCTS_ERROR:
      return {
        loading: false,
        error: action.payload,
      }
    default:
      return state
  }
}

Y por último, el archivo de la acción definirá una única función, encargada de obtener la lista de productos desde una API externa y de despachar la acción y el payload correctos:

import { GET_PRODUCTS, PRODUCTS_ERROR } from '../types'
import axios from 'axios'
import slugify from 'slugify'

export const getMakeUpProducts = () => async (dispatch: any) => {
  console.log('Getting the makeup products')

  try {
    let { data } = await axios.get(
      'https://makeup-api.herokuapp.com/api/v1/products.json?brand=maybelline&apiKey=123fff132'
    )
    const products = data

    let newProds = products.map((p: any) => {
      return {
        id: '' + p.id,
        slug: slugify(p.name),
        name: p.name,
        description: '',
        images: [{ url: p.image_link }],
        variants: [],
        price: {
          value: +p.price,
        },
        options: [],
      }
    })

    dispatch({
      type: GET_PRODUCTS,
      payload: newProds,
    })
  } catch (e) {
    dispatch({
      type: PRODUCTS_ERROR,
      payload: e,
    })
  }
}

Configurar el proveedor del tracker

Section titled Configurar el proveedor del tracker

Este proveedor te permitirá configurar un conjunto de plugins; en nuestro caso, usaremos el plugin de Redux, de esta manera (desde dentro del archivo _app.tsx)

//...more imports here....
import TrackerProvider from '../context/trackerProvider'
import trackerRedux from '@openreplay/tracker-redux'

// ... more code here....

export default function MyApp({ Component, pageProps }: AppProps) {
  const Layout = (Component as any).Layout || Noop

  useEffect(() => {
    document.body.classList?.remove('loading')
  }, [])

  let plugins = [
    {
      fn: trackerRedux,
      name: 'redux',
      config: {},
    },
  ]

  return (
    <TrackerProvider config={{ plugins }}>
      <Head />
      <ManagedUIContext>
        <Layout pageProps={pageProps}>
          <Component {...pageProps} />
        </Layout>
      </ManagedUIContext>
    </TrackerProvider>
  )
}

Ahora bien, este código te permite configurar el tracker con el plugin correcto, pero para que el plugin funcione, necesitaremos acceder al middleware que se devuelve cuando se llama al plugin. Eso significa que tendremos que mantener un registro de los valores devueltos por nuestros plugins para poder usarlos en otro lugar. En este caso, necesitaremos usarlo al llamar a la función createReduxStore de arriba.

Para hacerlo, tenemos que extender el TrackerProvider para asegurarnos de mantener el valor devuelto dentro del estado, de esta manera (puedes revisar la versión completa de este archivo aquí):

import { createContext, useCallback } from 'react'
import Tracker from '@openreplay/tracker'
import { v4 as uuidV4 } from 'uuid'
import { useReducer } from 'react'

export const TrackerContext = createContext()
function defaultGetUserId() {
  return uuidV4()
}
function newTracker(config) {
  ///code here
}
function reducer(state, action) {
  switch (action.type) {
    case 'init': {
      if (!state.tracker) {
        console.log('Instantiaing the tracker for the first time...')
        let t = newTracker(state.config)
        let pluginsReturnedValue = {}
        if (state.config.plugins) {
          state.config.plugins.forEach((p) => {
            console.log('Using plugin...')
            pluginsReturnedValue[p.name] = t.use(p.fn(p.config)) //keep track
          })
        }
        return {
          ...state,
          pluginsReturnedValue: pluginsReturnedValue, //update the state
          tracker: t,
        }
      }
      return state
    }
    case 'start': {
      console.log('Starting tracker...')
      state.tracker.start()
      return state
    }
  }
}
export default function TrackerProvider({ children, config = {} }) {
  let [state, dispatch] = useReducer(reducer, {
    tracker: null,
    pluginsReturnedValue: {},
    config,
  })
  let value = {
    startTracking: () => dispatch({ type: 'start' }),
    initTracker: () => dispatch({ type: 'init' }),
    pluginsReturnedValues: { ...state.pluginsReturnedValue }, //inject the state
  }
  return (
    <TrackerContext.Provider value={value}>{children}</TrackerContext.Provider>
  )
}

Dentro de la acción init, también mantenemos un registro de los valores devueltos por el método use cuando se llama con nuestros plugins. Y guardamos ese diccionario dentro de la propiedad state.pluginsReturnedValue. La cual ponemos a disposición de todos los componentes hijos a través de la variable pluginsReturnedValues.

Esa lógica te permite usar el plugin al inicializar el tracker y luego acceder al middleware y usarlo más adelante.

Crear el store de Redux con el nuevo middleware

Section titled Crear el store de Redux con el nuevo middleware

Ahora que tenemos el plugin funcionando, necesitamos inicializar el store de Redux, y debemos hacerlo después de que el Tracker se haya inicializado y antes de que se llame al método start.

Para ello, he elegido el componente ManagedUI, que se usa directamente en el archivo _app.tsx. Este componente está envuelto por nuestro Tracker Provider, lo que significa que tendrá acceso al contexto que estamos compartiendo.

El componente tiene este aspecto:

export const ManagedUIContext: FC = ({ children }) => {
  const { initTracker, pluginsReturnedValues } = useContext(TrackerContext)
  const [store, setStore] = useState<Store>()

  useEffect(() => {
    initTracker()
  }, [])

  useEffect(() => {
    if (!pluginsReturnedValues['redux']) return
    let middleWares = pluginsReturnedValues['redux']
      ? [pluginsReturnedValues['redux']]
      : []
    setStore(createReduxStore(middleWares))
  }, [pluginsReturnedValues])

  return (
    <div>
      {store && (
        <Provider store={store}>
          <UIProvider>
            <ThemeProvider>{children}</ThemeProvider>
          </UIProvider>
        </Provider>
      )}
    </div>
  )
}

Las conclusiones clave de este archivo son:

  1. Obtenemos la función initTracker y el atributo pluginsReturnedValues del contexto.
  2. Llamamos a la primera solo una vez, cuando el componente se monta (a través del primer useEffect).
  3. Luego creamos el store de Redux solo una vez que la variable pluginsReturnedValues tiene nuestro valor devuelto. El segundo useEffect se llamará dos veces, una cuando se carga la página y otra cuando el método initTracker modifica nuestra variable de estado. La segunda vez, crearemos el store con el middleware almacenado en pluginsReturnedValues.

Con el plugin configurado y el store de Redux creado correctamente, todo lo que tenemos que hacer ahora es llamar al método start del tracker.

La lógica para esto se añadirá en el archivo index.tsx, y puedes echar un vistazo al código fuente completo de este archivo aquí.

La parte relevante de este código que tendremos que examinar es la siguiente:

// imports and more logic goes here...

export default function Home({
  products,
}: InferGetStaticPropsType<typeof getStaticProps>) {

  const { startTracking } = useContext(TrackerContext)
  const dispatch = useDispatch()
  const makeUpProductsList = useSelector((state: any) => state.makeUpProducts)
  const { makeUpProducts } = makeUpProductsList

  useEffect(() => {
    async function getProds() {
      await startTracking()
      dispatch(getMakeUpProducts() as any)
    }
    getProds()
  }, [dispatch])

  return (
    <>
      <Grid variant="filled">
        {products.slice(0, 3).map((product: any, i: number) => (
          <ProductCard
            key={product.id}
            product={product}
            imgProps={{
              width: i === 0 ? 1080 : 540,
              height: i === 0 ? 1080 : 540,
              priority: true,
            }}
          />
        ))}
      </Grid>
      <Marquee variant="secondary">
        {makeUpProducts.slice(0, 3).map((product: any, i: number) => (
          <ProductCard key={product.id} product={product} variant="slim" />
        ))}
      </Marquee>
      <!-- more code here -->
    </>
  )
}

Solo vamos a usar la función startTracking del proveedor de contexto del Tracker y el hook useSelector de Redux para capturar la lista de productos devueltos.

El hook useEffect activará la llamada a startTracking y la obtención de la nueva lista de productos de maquillaje al despachar la llamada a la función getMakeUpProducts.

Puedes consultar este repositorio para ver el código fuente completo de una aplicación funcional basada en Next.js con un store de Redux.

Si tienes algún problema configurando el plugin de Redux, contáctanos en nuestra comunidad de Slack y pregúntale directamente a nuestros desarrolladores.