Interstellar Code

Guía de Redux Toolkit

Aprende a utilizar la librería mas popular para la gestión del estado global en aplicaciones de React, Redux Toolkit es una version moderna de lo que en su día fue Redux, esta librería nos permite manejar el estado global de nuestra aplicación de una manera sencilla y eficiente, abstrae muchos de los conceptos complejos que tenia Redux y nos permite crear un store de una manera mucho mas simple y rápida.

29 de agosto de 2025

Tabla de contenido:

  1. 1. Guía de Redux Toolkit
  2. 2. Configuración del store
  3. 3. Slices
  4. 4. Usando el store
  5. 5. Trabajar con Thunks
  6. 6. Middlewares y Cambios Optimistas

Guía de Redux Toolkit

Redux Toolkit es la librería oficial recomendada para la gestión del estado global en aplicaciones React. Proporciona una forma sencilla y eficiente de manejar el estado de la aplicación, simplificando muchas de las tareas que tradicionalmente eran complejas con Redux. Redux Toolkit abstrae conceptos complicados y ofrece una API intuitiva que facilita la creación y gestión del estado global.


Configuración del store

import { configureStore, type Middleware } from "@reduxjs/toolkit"

import { THEME_STORAGE_KEY } from "../../config/constants/storage.constant"
import { authSlice } from "./auth/auth.slice"
import { eventsSlice } from "./events/events.slice"
import { tasksSlice } from "./tasks/tasks.slice"
import { uiSlice } from "./ui/ui.slice"

const persistenceLocalStorageMiddleware: Middleware = (store) => (next) => (action) => {
  next(action)

  const currentTheme = store.getState().ui.theme

  window.localStorage.setItem(THEME_STORAGE_KEY, currentTheme)
  document.documentElement.setAttribute("data-theme", currentTheme)
}

export const store = configureStore({
  reducer: {
    auth: authSlice.reducer,
    ui: uiSlice.reducer,
    events: eventsSlice.reducer,
    tasks: tasksSlice.reducer,
  },
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware({
      serializableCheck: false,
      immutableCheck: false,
    }).concat(persistenceLocalStorageMiddleware),
})

export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch
import { Provider } from 'react-redux'
import Router from './Router'
import { store } from './store/store'

function App() {
  return (
    <Provider store={store}>
      <Router />
    </Provider>
  )
}

export default App

Slices

import { createSlice } from "@reduxjs/toolkit"
import { Views, type View } from "react-big-calendar"
import { CALENDAR_VIEW_STORAGE_KEY, TASK_VIEW_STORAGE_KEY, THEME_STORAGE_KEY } from "../../../config/constants/storage.constant"
import { THEMES_OPTIONS } from "../../../config/constants/themes.constant"
import type { Theme } from "../../../infrastructure/interfaces/theme.interface"

interface UIState {
  modal: null | "bell" | "settings" | "user"
  theme: Theme
  calendarView: View
  taskView: "list" | "kanban"
}

const initialState: UIState = (() => {
  const storedTheme = window.localStorage.getItem(THEME_STORAGE_KEY)
  const storedCalendarView = window.localStorage.getItem(CALENDAR_VIEW_STORAGE_KEY) as View || Views.WEEK
  const storedTaskView = window.localStorage.getItem(TASK_VIEW_STORAGE_KEY) || "list"

  if (!storedTheme) {
    window.localStorage.setItem(THEME_STORAGE_KEY, "indigo")

    return {
      modal: null,
      theme: "indigo",
      taskView: storedTaskView as "list" | "kanban",
      calendarView: storedCalendarView as View,
    }
  }

  return {
    modal: null,
    theme: storedTheme as Theme || "indigo",
    calendarView: storedCalendarView as View,
    taskView: storedTaskView as "list" | "kanban",
  }
})()

export const uiSlice = createSlice({
  initialState: initialState,
  name: "ui",
  reducers: {
    openModal: (state, action) => {
      state.modal = action.payload
    },
    closeModal: (state) => {
      state.modal = null
    },
    setTheme: (state, action) => {
      const newTheme = action.payload

      if (THEMES_OPTIONS.includes(newTheme)) {
        state.theme = newTheme
      }
    },
    setCalendarView: (state, action) => {
      const newView = action.payload

      if (Object.values(Views).includes(newView)) {
        state.calendarView = newView
        window.localStorage.setItem(CALENDAR_VIEW_STORAGE_KEY, newView)
      }
    },
    setTaskView: (state, action) => {
      const newView = action.payload

      if (newView === "list" || newView === "kanban") {
        state.taskView = newView
        window.localStorage.setItem(TASK_VIEW_STORAGE_KEY, newView)
      }
    },
  },
})

export const {
  openModal,
  closeModal,
  setTheme,
  setCalendarView,
  setTaskView,
} = uiSlice.actions

export default uiSlice.reducer
import { createSlice } from "@reduxjs/toolkit"
import type { User } from "../../../domain/entities/user.entity"

export interface AuthState {
  status: 'checking' | 'authenticated' | 'unauthenticated' | 'error'
  user: User
  errorMessage?: string
}

export const initialAuthState: AuthState = {
  status: 'checking',
  user: {
    id: '',
    name: '',
    fullName: '',
    email: '',
    avatarUrl: undefined,
  },
  errorMessage: undefined,
}

export const authSlice = createSlice({
  name: 'auth',
  initialState: initialAuthState,
  reducers: {
    onCheckingCredentials: (state) => {
      state.status = 'checking'
      state.user = initialAuthState.user
      state.errorMessage = undefined
    },

    login: (state, action) => {
      const user = action.payload as User

      state.status = 'authenticated'
      state.user = user
      state.errorMessage = undefined
    },

    logout: (state, action) => {
      state.status = 'unauthenticated'
      state.user = initialAuthState.user
      state.errorMessage = action.payload as string | undefined
    },

    onError: (state, action) => {
      state.status = 'error'
      state.errorMessage = action.payload as string
    },

    clearErrorMessage: (state) => {
      state.errorMessage = undefined
    }
  }
})

export const {
  onCheckingCredentials,
  login,
  logout,
  onError,
  clearErrorMessage
} = authSlice.actions

export default authSlice.reducer

Usando el store

import type { View } from "react-big-calendar"
import type { Theme } from "../../infrastructure/interfaces/theme.interface"
import { openModal, setCalendarView, setTaskView, setTheme } from "../store/ui/ui.slice"
import { useMeetlyDispatch, useMeetlySelector } from "./use-store"

export default function useUI() {
  const { modal, theme, calendarView, taskView } = useMeetlySelector((state) => state.ui)
  const dispatch = useMeetlyDispatch()

  const handleOpenModal = (modalType: "bell" | "settings" | "user") => {
    dispatch(openModal(modalType))
  }

  const handleCloseModal = () => {
    dispatch(openModal(null))
  }

  const handleSetTheme = (newTheme: Theme) => {
    dispatch(setTheme(newTheme))
  }

  const handleSetCalendarView = (newView: View) => {
    dispatch(setCalendarView(newView))
  }

  const handleSetTaskView = (newView: "list" | "kanban") => {
    dispatch(setTaskView(newView))
  }

  return {
    modal,
    theme,
    calendarView,
    taskView,

    openModal: handleOpenModal,
    closeModal: handleCloseModal,
    setTheme: handleSetTheme,
    setCalendarView: handleSetCalendarView,
    setTaskView: handleSetTaskView,
  }
}

Trabajar con Thunks

import { createSlice } from '@reduxjs/toolkit'

export const journalSlice = createSlice({
  name: 'journal',
  initialState: {
    isSaving: false,
    messageSaved: '',
    notes: [],
    active: null,
  },
  reducers: {
    savingNewNote: (state) => {
      state.isSaving = true
    },
    addNewEmptyNote: (state, action) => {
      state.notes.push(action.payload)
      state.isSaving = false
    },
    setActiveNote: (state, action) => {
      state.active = action.payload
      state.messageSaved = ''
    },
    setNotes: (state, action) => {
      state.notes = action.payload
    },
    setSaving: (state) => {
      state.isSaving = true
      state.messageSaved = ''
    },
    updateNote: (state, action) => {
      state.isSaving = false

      state.notes = state.notes.map((note) => {
        if (note.id === action.payload.id) {
          return action.payload
        }

        return note
      })

      state.messageSaved = `${action.payload.title}, actualizada correctamente`
    },
    setPhotosToActiveNote: (state, action) => {
      state.active.imageUrls = [...state.active.imageUrls, ...action.payload]
      state.isSaving = false
    },
    clearNotesLogout: (state) => {
      state.isSaving = false
      state.messageSaved = ''
      state.notes = []
      state.active = null
    },
    deleteNoteById: (state, action) => {
      state.active = null
      state.notes = state.notes.filter((note) => note.id !== action.payload)
    }
  }
})

export const {
  addNewEmptyNote,
  clearNotesLogout,
  deleteNoteById,
  savingNewNote,
  setActiveNote,
  setNotes,
  setPhotosToActiveNote,
  setSaving,
  updateNote
} = journalSlice.actions
import { collection, deleteDoc, doc, setDoc } from 'firebase/firestore'

import { FirebaseDB } from '../../firebase/config'
import { fileUpload, loadNotes } from '../../helpers'
import { addNewEmptyNote, deleteNoteById, savingNewNote, setActiveNote, setNotes, setPhotosToActiveNote, setSaving, updateNote } from './journalSlice'

export const startNewNote = () => {
  return async (dispatch, getState) => {
    dispatch(savingNewNote())

    const { uid } = getState().auth

    const newNote = {
      title: '',
      body: '',
      date: new Date().getTime(),
    }

    const newDoc = doc(collection(FirebaseDB, `${uid}/journal/notes`))
    await setDoc(newDoc, newNote)

    newNote.id = newDoc.id

    dispatch(addNewEmptyNote(newNote))
    dispatch(setActiveNote(newNote))
  }
}

export const startLoadingNotes = () => {
  return async (dispatch, getState) => {
    const { uid } = getState().auth
    if (!uid) throw new Error('El UID del usuario no existe')

    const notesCollection = await loadNotes(uid)
    dispatch(setNotes(notesCollection))
  }
}

export const startSaveNote = () => {
  return async (dispatch, getState) => {
    dispatch(setSaving())

    const { uid } = getState().auth
    const { active: note } = getState().journal

    const noteToFireStore = { ...note }
    delete noteToFireStore.id

    const docRef = doc(FirebaseDB, `${uid}/journal/notes/${note.id}`)
    await setDoc(docRef, noteToFireStore, { merge: true })

    dispatch(updateNote(note))
  }
}

export const startUploadingFiles = (files = []) => {
  return async (dispatch) => {
    dispatch(setSaving())

    const filesUploadPromises = []

    for (const file of files) {
      filesUploadPromises.push(fileUpload(file))
    }

    const photosUrls = await Promise.all(filesUploadPromises)

    dispatch(setPhotosToActiveNote(photosUrls))
  }
}

export const startDeletingNote = () => {
  return async (dispatch, getState) => {
    const { uid } = getState().auth
    const { active: note } = getState().journal

    const docRef = doc(FirebaseDB, `${uid}/journal/notes/${note.id}`)
    await deleteDoc(docRef)

    dispatch(deleteNoteById(note.id))
  }
}
import { useDispatch, useSelector } from 'react-redux'
import { startNewNote } from '../store/journal/thunks'

export const JournalPage = () => {
  const dispatch = useDispatch()
  const { isSaving } = useSelector((state) => state.journal)

  const handleClickNewNote = () => {
    dispatch(startNewNote())
  }

  return (
    <button
      disabled={isSaving}
      onClick={handleClickNewNote}
      className='btn btn-primary'
    >
      Nueva nota
    </button>
  )
}

Middlewares y Cambios Optimistas

import { configureStore, type Middleware } from '@reduxjs/toolkit'
import { toast } from 'sonner'

import usersReducer, { rollbackUser } from './users/slice'
import uiReducer from './ui/slice'

/* Creamos un middleware para redux, donde la store tiene acceso al estado, 
next es la función que va a hacer que se continue con el reducer que va a cambiar
el estado, y la action es la acción que se esta ejecutando con dispatch mediante
la cual se va a modificar el estado */
const persistanceLocalStorageMiddleware: Middleware = (store) => (next) => (action) => {
  // Previous state before the action
  // console.log(store.getState()) // Accedemos al valor antes de ser modificado

  next(action) // Continuamos con la acción que se va a ejecutar

  // New state after the action
  // console.log(store.getState()) // Accedemos al valor después de ser modificado

  window.localStorage.setItem('__redux__state__', JSON.stringify(store.getState()))
}

const syncWithDatabaseMiddleware: Middleware = (store) => (next) => (action) => {
  const { type, payload } = action // Obtenemos el type y el payload de la acción
  const previousState = store.getState() as RootState // Accedemos al estado previo

  next(action)

  /* Evaluamos la acción ejecutada en el slice users */
  if (type === 'users/deleteUserById') { // Eliminando el usuario
    const userIdToRemove = payload
    const userToRemove = previousState.users.find((user) => user.id === userIdToRemove)

    fetch(`https://jsonplaceholder.typicode.com/users/${userIdToRemove}`, {
			method: 'DELETE'
		})
      .then((res) => {
        if (res.ok) {
          toast.success(`Usuario ${payload} eliminado correctamente`)
        }

        // throw new Error('Error al eliminar el usuario')
      })
      .catch((err) => {
        toast.error(`Error al eliminar el usuario ${userIdToRemove}`)
        if (userToRemove) store.dispatch(rollbackUser(userToRemove))

        console.log(err)
        console.log('Error')
      })
  }
}

/* Creamos nuestra store para Redux, la cual sera como una pizza en la cual va a tener
muchas rebanadas (slices) de estados diferentes, los cuales se conocen como reducers,
donde por ejemplo tenemos un slice para almacenar el estado de los usuarios y otra
para almacenar el estado de la interfaz de usuario. También podemos configurar
los middlewares, los cuales son funciones las cuales se van a ejecutar cuando una acción
es lanzada con un dispatch */
export const store = configureStore({
  reducer: {
    users: usersReducer,
    ui: uiReducer,
  },
  middleware: [
    persistanceLocalStorageMiddleware,
    syncWithDatabaseMiddleware,
  ],
})

/* Exportamos el estado global de nuestra store como RootState */
export type RootState = ReturnType<typeof store.getState>
/* Exportamos el dispatch para acciones de nuestra store como AppDispatch */
export type AppDispatch = typeof store.dispatch