Gestión de Estado Centralizada en React Native con Redux Toolkit y Persistencia
Este tutorial te guiará paso a paso en la implementación de una gestión de estado centralizada en tus aplicaciones React Native utilizando Redux Toolkit. Descubrirás cómo simplificar la lógica de Redux y añadir persistencia al estado para que tus datos sobrevivan a los reinicios de la aplicación. Ideal para desarrolladores que buscan escalar sus proyectos con una arquitectura de estado robusta.
🚀 Introducción a la Gestión de Estado en React Native
Desarrollar aplicaciones móviles con React Native es una experiencia increíble, pero a medida que tu aplicación crece, la gestión del estado se convierte en un desafío. ¿Dónde almacenas los datos del usuario? ¿Cómo compartes información entre componentes que no están directamente relacionados? La respuesta a menudo reside en una gestión de estado centralizada.
En este tutorial, exploraremos una de las soluciones más populares y robustas para este problema: Redux Toolkit. Además, abordaremos un requisito común en las aplicaciones modernas: la persistencia del estado, es decir, cómo asegurar que los datos de tu aplicación no se pierdan cuando el usuario cierra y vuelve a abrir la app. Para ello, utilizaremos Redux Persist.
¿Por qué Redux Toolkit? 🤔
Redux tradicionalmente ha sido conocido por su curva de aprendizaje empinada y la cantidad de boilerplate (código repetitivo) que requería. Redux Toolkit fue creado para abordar estas preocupaciones, ofreciendo una experiencia de desarrollo mucho más amigable y eficiente. Algunas de sus ventajas clave incluyen:
- Menos boilerplate: Funciones como
createSliceycreateAsyncThunkreducen drásticamente la cantidad de código que necesitas escribir. - Opiniones predeterminadas: Incluye herramientas comunes como Redux Thunk para manejar acciones asíncronas y
Immerpara mutaciones inmutables, out of the box. - Mejor experiencia de desarrollo: Facilita la configuración y el uso de Redux, haciéndolo accesible para un público más amplio.
¿Y Redux Persist? 💾
Imagina una aplicación donde el usuario inicia sesión y, al cerrar la app, tiene que volver a iniciar sesión. Frustrante, ¿verdad? Redux Persist es la solución a este problema. Permite almacenar el estado de Redux en el almacenamiento local del dispositivo (como AsyncStorage en React Native) y rehidratarlo cuando la aplicación se inicia de nuevo. Esto garantiza una experiencia de usuario fluida y consistente.
🛠️ Configuración Inicial del Proyecto React Native
Antes de sumergirnos en Redux, necesitamos un proyecto base de React Native. Si ya tienes uno, puedes saltarte este paso. De lo contrario, sigamos estos sencillos pasos.
1. Crear un Nuevo Proyecto 🏗️
Utilizaremos Expo CLI para crear un nuevo proyecto. Expo simplifica mucho el desarrollo de React Native.
npx create-expo-app my-redux-app
cd my-redux-app
2. Instalar Dependencias de Redux Toolkit y React-Redux 📦
Ahora, instalaremos las bibliotecas necesarias. Redux Toolkit incluye Redux y Redux Thunk, por lo que solo necesitamos instalarlo junto con react-redux para conectar nuestro estado Redux a los componentes de React Native.
npm install @reduxjs/toolkit react-redux
# o
yarn add @reduxjs/toolkit react-redux
3. Instalar Dependencias de Redux Persist y Almacenamiento 🗄️
Para la persistencia, necesitaremos redux-persist y una capa de almacenamiento. En React Native, AsyncStorage es la opción estándar.
npm install redux-persist @react-native-async-storage/async-storage
# o
yarn add redux-persist @react-native-async-storage/async-storage
⚛️ Creando tu Primera Slice con Redux Toolkit
En Redux Toolkit, el concepto central es la slice. Una slice es una pequeña porción de tu estado global, junto con sus reducers y acciones asociados. Esto agrupa la lógica de forma concisa.
Vamos a crear una slice simple para gestionar un contador.
1. Estructura de Carpetas 📁
Es una buena práctica organizar tu lógica Redux en una carpeta dedicada. Crea una carpeta store en la raíz de tu proyecto.
my-redux-app/
├── App.js
├── src/
│ ├── components/
│ └── screens/
└── store/
└── counterSlice.js
└── index.js
2. Definición de counterSlice.js 🔢
Crea el archivo store/counterSlice.js con el siguiente contenido:
// store/counterSlice.js
import { createSlice } from '@reduxjs/toolkit';
const initialState = {
value: 0,
};
export const counterSlice = createSlice({
name: 'counter',
initialState,
reducers: {
increment: (state) => {
// Redux Toolkit usa Immer, así que podemos "mutar" el estado directamente
// En realidad, Immer se encarga de crear un nuevo estado inmutable por nosotros
state.value += 1;
},
decrement: (state) => {
state.value -= 1;
},
incrementByAmount: (state, action) => {
state.value += action.payload;
},
reset: (state) => {
state.value = 0;
},
},
});
// Action creators se generan automáticamente para cada función reducer que definamos
export const { increment, decrement, incrementByAmount, reset } = counterSlice.actions;
// El reducer de la slice lo exportamos para añadirlo al store global
export default counterSlice.reducer;
🔄 Configurando el Store de Redux con Persistencia
Ahora que tenemos nuestra primera slice, es hora de configurar el store global de Redux y habilitar la persistencia.
1. Definición de index.js para el Store 📦
Crea el archivo store/index.js:
// store/index.js
import { configureStore, combineReducers } from '@reduxjs/toolkit';
import {
persistStore,
persistReducer,
FLUSH,
REHYDRATE,
PAUSE,
PERSIST,
PURGE,
REGISTER
} from 'redux-persist';
import AsyncStorage from '@react-native-async-storage/async-storage';
import counterReducer from './counterSlice';
// Configuración para redux-persist
const persistConfig = {
key: 'root', // Clave para el almacenamiento
storage: AsyncStorage, // El motor de almacenamiento que queremos usar
// whitelist: ['counter'], // Opcional: solo persistir estas slices (por nombre)
// blacklist: ['someOtherSlice'], // Opcional: no persistir estas slices
};
// Combina todos los reducers de tus slices
const rootReducer = combineReducers({
counter: counterReducer,
// Aquí puedes añadir más slices:
// user: userReducer,
// settings: settingsReducer,
});
// Envuelve el rootReducer con persistReducer para habilitar la persistencia
const persistedReducer = persistReducer(persistConfig, rootReducer);
export const store = configureStore({
reducer: persistedReducer,
// Middleware por defecto incluye redux-thunk y maneja serialización
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: {
// Ignorar estas acciones para que redux-persist no cause advertencias de serialización
ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER],
},
}),
});
export const persistor = persistStore(store);
Explicación de la Configuración del Store 🧐
persistConfig: Define cómo Redux Persist debe guardar y cargar el estado.keyes un identificador, ystoragees el mecanismo de almacenamiento (aquí,AsyncStorage).whitelistyblacklistson útiles para controlar qué partes del estado se persisten.combineReducers: Une todos tus reducers de slices en un único root reducer. Así es como Redux sabe qué reducer es responsable de qué parte del estado.persistReducer: Envuelve tu root reducer con la configuración de persistencia. Esto le dice a Redux Persist qué reducer debe persistir.configureStore: La función principal de Redux Toolkit para crear el store. Toma elpersistedReducer.middleware: Es crucial configurar elserializableCheckpara ignorar las acciones de Redux Persist. Sin esto, verías advertencias en la consola porque Redux Persist envía acciones que no son serializables (es decir, que no pueden convertirse fácilmente a JSON y viceversa).persistStore(store): Esta función crea elpersistor, que es responsable de rehidratar el estado persistido. Lo exportamos para usarlo enApp.js.
🌐 Conectando React Native con Redux
Con el store configurado, el siguiente paso es conectar nuestra aplicación React Native a Redux. Esto se hace principalmente a través del componente Provider de react-redux y PersistGate de redux-persist.
1. Modificar App.js ⚛️
Actualiza tu App.js para envolver tu aplicación con Provider y PersistGate:
// App.js
import React from 'react';
import { View, Text, Button, StyleSheet } from 'react-native';
import { Provider } from 'react-redux';
import { PersistGate } from 'redux-persist/integration/react';
import { store, persistor } from './store';
import CounterScreen from './src/screens/CounterScreen'; // Crearemos esto en el siguiente paso
export default function App() {
return (
<Provider store={store}>
<PersistGate loading={<Text>Cargando persistencia...</Text>} persistor={persistor}>
<CounterScreen />
</PersistGate>
</Provider>
);
}
// Estilos básicos (opcional)
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
});
Provider store={store}: Hace que el store de Redux esté disponible para todos los componentes anidados. Es el componente raíz de tu aplicación Redux.PersistGate persistor={persistor} loading={...}: Este componente retrasa el renderizado de la UI hasta que tu estado persistido haya sido recuperado y guardado en Redux. El proploadingacepta cualquier elemento de React que quieras mostrar mientras se rehidrata el estado (una pantalla de carga, un spinner, etc.).
📱 Utilizando el Estado Redux en Componentes
Ahora que todo está configurado, podemos empezar a interactuar con nuestro estado Redux desde un componente React Native.
1. Crear CounterScreen.js 📈
Crea un nuevo archivo src/screens/CounterScreen.js:
// src/screens/CounterScreen.js
import React from 'react';
import { View, Text, Button, StyleSheet } from 'react-native';
import { useSelector, useDispatch } from 'react-redux';
import { increment, decrement, incrementByAmount, reset } from '../../store/counterSlice';
const CounterScreen = () => {
// `useSelector` se usa para extraer datos del store de Redux
const count = useSelector((state) => state.counter.value);
// `useDispatch` se usa para obtener la función `dispatch`
// que permite enviar acciones al store
const dispatch = useDispatch();
return (
<View style={styles.container}>
<Text style={styles.title}>Contador Redux Persist</Text>
<Text style={styles.countText}>Valor: {count}</Text>
<View style={styles.buttonContainer}>
<Button title="Incrementar" onPress={() => dispatch(increment())} />
<Button title="Decrementar" onPress={() => dispatch(decrement())} />
</View>
<View style={styles.buttonContainer}>
<Button title="Sumar 5" onPress={() => dispatch(incrementByAmount(5))} />
<Button title="Reiniciar" onPress={() => dispatch(reset())} />
</View>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#f0f0f0',
padding: 20,
},
title: {
fontSize: 28,
fontWeight: 'bold',
marginBottom: 30,
color: '#333',
},
countText: {
fontSize: 48,
fontWeight: 'bold',
marginBottom: 40,
color: '#007bff',
},
buttonContainer: {
flexDirection: 'row',
justifyContent: 'space-around',
width: '80%',
marginBottom: 20,
},
});
export default CounterScreen;
Explicación de CounterScreen.js 🤓
useSelector: Este hook te permite seleccionar una parte del estado de tu store de Redux. Recibe una función selectora que toma el estado global y devuelve el dato que te interesa (state.counter.valueen este caso). Cada vez questate.counter.valuecambie, tu componente se volverá a renderizar con el nuevo valor.useDispatch: Este hook te da acceso a la funcióndispatchdel store. Usasdispatchpara enviar acciones a tu store, lo que a su vez activa los reducers y actualiza el estado. Por ejemplo,dispatch(increment())envía la acciónincrementque definimos encounterSlice.
✨ Probando la Persistencia del Estado
¡Es hora de ver la persistencia en acción!
- Inicia tu aplicación:
npm start
# o
yarn start
Abre tu aplicación en un emulador o dispositivo real.
2. Interactúa con el contador: Presiona los botones para incrementar y decrementar el contador. Observa cómo el valor cambia.
-
Cierra y reabre la aplicación:
- Si estás en Expo Go, simplemente cierra la aplicación desde la multitarea o el menú de apps recientes de tu dispositivo. Luego, vuelve a abrirla.
- Si estás en un emulador, puedes detener el proceso de desarrollo (
Ctrl+ C en la terminal) y volver a iniciarlo, o simplemente cerrar la app del emulador y reabrirla.
Deberías notar que el contador mantiene el último valor que tenía antes de cerrar la aplicación. ¡Esto significa que Redux Persist está funcionando correctamente!
📝 Añadiendo otra Slice: Gestión de Tareas (Ejemplo Avanzado)
Para demostrar cómo escalar tu aplicación, vamos a añadir una segunda slice para la gestión de tareas. Esto incluirá acciones síncronas y asíncronas para simular la carga de datos.
1. Definición de tasksSlice.js 📋
Crea el archivo store/tasksSlice.js:
// store/tasksSlice.js
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
// createAsyncThunk es útil para manejar acciones asíncronas (peticiones a APIs, etc.)
export const fetchTasks = createAsyncThunk(
'tasks/fetchTasks', // Tipo de acción base
async () => {
// Simular una llamada a API
const response = await new Promise(resolve => setTimeout(() => {
resolve([
{ id: '1', text: 'Aprender Redux Toolkit', completed: true },
{ id: '2', text: 'Construir app RN', completed: false },
{ id: '3', text: 'Publicar en tiendas', completed: false },
]);
}, 1000)); // Espera 1 segundo
return response;
}
);
const initialState = {
tasks: [],
status: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed'
error: null,
};
export const tasksSlice = createSlice({
name: 'tasks',
initialState,
reducers: {
addTask: (state, action) => {
state.tasks.push({
id: Date.now().toString(),
text: action.payload,
completed: false,
});
},
toggleTask: (state, action) => {
const task = state.tasks.find(task => task.id === action.payload);
if (task) {
task.completed = !task.completed;
}
},
deleteTask: (state, action) => {
state.tasks = state.tasks.filter(task => task.id !== action.payload);
},
},
// `extraReducers` maneja acciones que no son generadas por esta slice (ej. de createAsyncThunk)
extraReducers: (builder) => {
builder
.addCase(fetchTasks.pending, (state) => {
state.status = 'loading';
})
.addCase(fetchTasks.fulfilled, (state, action) => {
state.status = 'succeeded';
state.tasks = action.payload;
})
.addCase(fetchTasks.rejected, (state, action) => {
state.status = 'failed';
state.error = action.error.message;
});
},
});
export const { addTask, toggleTask, deleteTask } = tasksSlice.actions;
export default tasksSlice.reducer;
2. Actualizar store/index.js 🌐
Necesitamos añadir la nueva slice al rootReducer:
// store/index.js (actualizado)
import { configureStore, combineReducers } from '@reduxjs/toolkit';
import {
persistStore,
persistReducer,
FLUSH,
REHYDRATE,
PAUSE,
PERSIST,
PURGE,
REGISTER
} from 'redux-persist';
import AsyncStorage from '@react-native-async-storage/async-storage';
import counterReducer from './counterSlice';
import tasksReducer from './tasksSlice'; // Importa la nueva slice
const persistConfig = {
key: 'root',
storage: AsyncStorage,
whitelist: ['counter', 'tasks'], // Añade 'tasks' a la whitelist para persistir también las tareas
};
const rootReducer = combineReducers({
counter: counterReducer,
tasks: tasksReducer, // Añade la nueva slice aquí
});
const persistedReducer = persistReducer(persistConfig, rootReducer);
export const store = configureStore({
reducer: persistedReducer,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: {
ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER],
},
}),
});
export const persistor = persistStore(store);
3. Crear TaskScreen.js 📝
Crea el archivo src/screens/TaskScreen.js para interactuar con las tareas:
// src/screens/TaskScreen.js
import React, { useEffect, useState } from 'react';
import { View, Text, Button, StyleSheet, FlatList, TouchableOpacity, TextInput, ActivityIndicator } from 'react-native';
import { useSelector, useDispatch } from 'react-redux';
import { addTask, toggleTask, deleteTask, fetchTasks } from '../../store/tasksSlice';
const TaskScreen = () => {
const tasks = useSelector((state) => state.tasks.tasks);
const status = useSelector((state) => state.tasks.status);
const error = useSelector((state) => state.tasks.error);
const dispatch = useDispatch();
const [newTaskText, setNewTaskText] = useState('');
useEffect(() => {
if (status === 'idle') {
dispatch(fetchTasks()); // Cargar tareas al iniciar la pantalla si el estado es 'idle'
}
}, [status, dispatch]);
const handleAddTask = () => {
if (newTaskText.trim()) {
dispatch(addTask(newTaskText.trim()));
setNewTaskText('');
}
};
const renderTask = ({ item }) => (
<View style={styles.taskItem}>
<TouchableOpacity onPress={() => dispatch(toggleTask(item.id))} style={styles.taskTextContainer}>
<Text style={[styles.taskText, item.completed && styles.completedTaskText]}>
{item.text}
</Text>
</TouchableOpacity>
<TouchableOpacity onPress={() => dispatch(deleteTask(item.id))} style={styles.deleteButton}>
<Text style={styles.deleteButtonText}>X</Text>
</TouchableOpacity>
</View>
);
return (
<View style={styles.container}>
<Text style={styles.title}>Lista de Tareas (Redux Persist)</Text>
<View style={styles.inputContainer}>
<TextInput
style={styles.input}
placeholder="Añadir nueva tarea..."
value={newTaskText}
onChangeText={setNewTaskText}
/>
<Button title="Añadir" onPress={handleAddTask} />
</View>
{status === 'loading' && <ActivityIndicator size="large" color="#0000ff" />}
{status === 'failed' && <Text style={styles.errorText}>Error: {error}</Text>}
<FlatList
data={tasks}
keyExtractor={(item) => item.id}
renderItem={renderTask}
style={styles.taskList}
contentContainerStyle={{ paddingBottom: 20 }}
/>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
paddingTop: 50,
paddingHorizontal: 20,
backgroundColor: '#fff',
},
title: {
fontSize: 26,
fontWeight: 'bold',
marginBottom: 20,
textAlign: 'center',
color: '#333',
},
inputContainer: {
flexDirection: 'row',
marginBottom: 20,
},
input: {
flex: 1,
borderColor: '#ccc',
borderWidth: 1,
padding: 10,
marginRight: 10,
borderRadius: 5,
},
taskList: {
flex: 1,
width: '100%',
},
taskItem: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 15,
backgroundColor: '#f9f9f9',
borderBottomWidth: 1,
borderBottomColor: '#eee',
marginBottom: 5,
borderRadius: 8,
},
taskTextContainer: {
flex: 1,
marginRight: 10,
},
taskText: {
fontSize: 18,
color: '#555',
},
completedTaskText: {
textDecorationLine: 'line-through',
color: '#aaa',
},
deleteButton: {
backgroundColor: '#ff4d4d',
paddingHorizontal: 10,
paddingVertical: 5,
borderRadius: 5,
},
deleteButtonText: {
color: '#fff',
fontWeight: 'bold',
},
errorText: {
color: 'red',
textAlign: 'center',
marginBottom: 10,
},
});
export default TaskScreen;
4. Actualizar App.js para usar TaskScreen (o ambos) 🎨
Puedes cambiar App.js para mostrar TaskScreen o, si quieres ver ambos, puedes usar un tab navigator o simplemente mostrarlos secuencialmente para este ejemplo.
Para simplicidad, reemplazaremos CounterScreen con TaskScreen en App.js:
// App.js (actualizado)
import React from 'react';
import { View, Text, Button, StyleSheet } from 'react-native';
import { Provider } from 'react-redux';
import { PersistGate } from 'redux-persist/integration/react';
import { store, persistor } from './store';
import TaskScreen from './src/screens/TaskScreen'; // Usaremos la pantalla de tareas
export default function App() {
return (
<Provider store={store}>
<PersistGate loading={<Text>Cargando persistencia...</Text>} persistor={persistor}>
<TaskScreen />
</PersistGate>
</Provider>
);
}
Reinicia tu aplicación y verás la lista de tareas. Añade, marca como completada y elimina tareas. Luego, cierra la app y vuelve a abrirla para verificar que las tareas persisten.
💡 Consideraciones Adicionales y Mejores Prácticas
Has implementado una potente gestión de estado. Aquí hay algunas cosas más a tener en cuenta:
Limpieza y Purga del Estado Persistido 🧹
En ocasiones, querrás eliminar completamente el estado persistido. Esto es útil para pruebas o cuando se produce un error en el formato de los datos guardados. Puedes purgar el estado usando persistor.purge().
import { persistor } from './store';
// En algún lugar de tu código (por ejemplo, un botón de "Cerrar sesión" o "Reiniciar app")
const clearPersistedState = async () => {
try {
await persistor.purge();
console.log('Estado persistido purgado correctamente.');
// Opcional: recargar la app o el store
} catch (error) {
console.error('Error al purgar el estado persistido:', error);
}
};
Gestión de Estado Asíncrono más Compleja 🌐
createAsyncThunk es excelente para la mayoría de los casos. Para escenarios más avanzados que requieren lógica asíncrona que coordina múltiples dispatches o accede al estado de forma más compleja, podrías considerar:
- RTK Query: Una biblioteca adicional de Redux Toolkit para simplificar la gestión de datos de APIs. Es una alternativa más potente a
createAsyncThunkpara el fetching y caching de datos. - Middleware personalizado: Para lógica muy específica que no encaja en thunks o reducers.
Selectores Optimizados con reselect 🎯
A medida que tu estado crece, tus useSelector pueden volverse menos eficientes si realizan cálculos complejos. La librería reselect (integrada en Redux Toolkit) te permite crear selectores memorizados que solo recalculan un valor cuando sus entradas cambian. Esto evita recálculos innecesarios y optimiza el rendimiento.
// store/tasksSlice.js (ejemplo con reselect)
import { createSelector } from '@reduxjs/toolkit';
// ... (código existente)
// Selector para obtener solo las tareas completadas
export const selectCompletedTasks = createSelector(
(state) => state.tasks.tasks, // Primera función de entrada: obtiene todas las tareas
(tasks) => tasks.filter(task => task.completed) // Segunda función: filtra las tareas
);
// Uso en un componente:
// const completedTasks = useSelector(selectCompletedTasks);
Estructura de Proyecto para Aplicaciones Grandes 🏛️
Para aplicaciones muy grandes, puedes considerar una estructura de carpetas basada en "características" o "dominios" en lugar de por "tipo" (reducers, actions, etc.). Cada carpeta de característica contendría su propia slice, componentes, selectores, etc.
src/
├── features/
│ ├── auth/
│ │ ├── authSlice.js
│ │ ├── AuthScreen.js
│ │ └── selectors.js
│ ├── tasks/
│ │ ├── tasksSlice.js
│ │ ├── TaskList.js
│ │ └── selectors.js
│ └── ...
└── store/
└── index.js // El store central que combina todos los reducers de features
✅ Conclusión
¡Felicidades! Has completado un tutorial exhaustivo sobre la gestión de estado en React Native utilizando Redux Toolkit y Redux Persist. Ahora tienes las herramientas para construir aplicaciones React Native con una arquitectura de estado escalable, mantenible y persistente.
Dominar estos conceptos es un paso crucial para el desarrollo de aplicaciones móviles de alta calidad. Experimenta con diferentes slices, añade más complejidad a tus reducers y explora las muchas funcionalidades que Redux Toolkit ofrece.
Recuerda practicar y aplicar estos conocimientos en tus propios proyectos. ¡El camino para ser un experto en React Native está lleno de aprendizaje continuo y desarrollo!
Tutoriales relacionados
Comentarios (0)
Aún no hay comentarios. ¡Sé el primero!