Arquitectura de Estado en Aplicaciones React: De Local a Global

Arquitectura de Estado en Aplicaciones React: De Local a Global
Photo by Alfons Morales / Unsplash

Introducción

El flujo de datos y la gestión del estado representan el núcleo de toda aplicación React moderna. Lo que comenzó como un simple sistema de props y state ha evolucionado hacia un ecosistema complejo de soluciones que responden a necesidades cada vez más sofisticadas.

A medida que nuestras aplicaciones crecen, la gestión del estado se convierte en un desafío arquitectónico fundamental. ¿Dónde debe residir cada pieza de información? ¿Qué componentes deberían tener acceso a ella? ¿Cómo podemos mantener la sincronización cuando los datos cambian? Las respuestas a estas preguntas definen la arquitectura de nuestra aplicación y, en última instancia, determinan su mantenibilidad y escalabilidad.

En este artículo, exploraremos el espectro completo de la gestión de estado en React, desde el ámbito más local hasta las soluciones globales más sofisticadas. Analizaremos cuándo y cómo utilizar cada enfoque, identificando patrones y anti-patrones que pueden marcar la diferencia entre una aplicación robusta y una frágil.

💡 Pro tip
La gestión de estado no es un problema técnico, sino un desafío de diseño. La tecnología que elijas importa menos que la arquitectura que diseñes.

Anatomía del estado en React

Antes de sumergirnos en soluciones específicas, necesitamos un marco conceptual para entender los diferentes tipos de estado que existen en una aplicación React. Esta taxonomía nos permitirá tomar decisiones más informadas sobre qué herramientas utilizar en cada situación.

Tipos de estado según su propósito

El estado en una aplicación React puede clasificarse en cinco categorías principales según su propósito:

  • Estado de UI: Controla aspectos visuales como modales abiertos, pestañas activas, animaciones o temas
  • Estado de formulario: Gestiona inputs, validaciones y el proceso de envío de datos
  • Estado de entidad: Representa los datos del dominio de negocio (usuarios, productos, pedidos)
  • Estado de comunicación: Refleja el estado de operaciones asíncronas y comunicación con servicios externos
  • Estado de navegación: Determina qué vista debe mostrarse al usuario

Cada tipo responde a necesidades diferentes y puede requerir estrategias de gestión distintas.

Clasificación por alcance y persistencia

Otra dimensión crucial para entender el estado es su alcance: desde el componente individual hasta toda la aplicación:

  • Estado local: Relevante solo para un componente específico
  • Estado compartido: Necesario para un subárbol de componentes
  • Estado global: Accesible desde cualquier parte de la aplicación

La persistencia representa otra dimensión importante:

  • Estado transitorio: Existe solo durante la sesión actual (ej. formularios)
  • Estado persistente: Debe conservarse entre sesiones (ej. preferencias de usuario)
  • Estado derivado: Calculado a partir de otro estado (no requiere almacenamiento propio)

Identificando correctamente el tipo de estado

La clasificación adecuada de cada pieza de estado es el primer paso hacia una arquitectura sólida. Algunas preguntas clave que debemos hacernos:

  • ¿Qué componentes necesitan leer este estado?
  • ¿Qué componentes necesitan modificarlo?
  • ¿Debe persistir este estado entre renderizados o sesiones?
  • ¿Es un estado derivado que podría calcularse a partir de otro estado existente?

La respuesta a estas preguntas nos guiará hacia la solución más apropiada, siguiendo un principio fundamental: mantener el estado lo más local posible.

🔍 Detalle técnico
Un error común es almacenar datos derivados como estado. Si un valor puede calcularse a partir de otro estado o props, generalmente no debería almacenarse como estado independiente. Esta práctica evita problemas de sincronización.

Estado local: useState y useReducer

El estado local representa la forma más básica de gestión de estado en React. Es el punto de partida perfecto para cualquier tipo de dato que solo necesite ser accesible dentro de un componente específico.

Cuándo usar useState vs useReducer

React nos ofrece dos hooks principales para gestionar estado local: useState y useReducer. La elección entre ellos no es trivial y puede afectar significativamente la mantenibilidad de nuestro código.

useState es ideal cuando:

  • El estado es simple (valores primitivos o objetos simples)
  • Las actualizaciones son independientes y no dependen del valor anterior
  • La lógica de actualización es simple
function Counter() {
  const [count, setCount] = useState(0);
  
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>
        Increment
      </button>
    </div>
  );
}

useReducer brilla cuando:

  • El estado es complejo (objetos anidados o arrays)
  • Las actualizaciones dependen del estado anterior
  • Múltiples eventos pueden desencadenar cambios relacionados
  • La lógica de actualización es compleja o debe reutilizarse
function todoReducer(state, action) {
  switch (action.type) {
    case 'ADD':
      return [...state, { id: Date.now(), text: action.text, completed: false }];
    case 'TOGGLE':
      return state.map(todo => 
        todo.id === action.id ? { ...todo, completed: !todo.completed } : todo
      );
    default:
      return state;
  }
}

function TodoList() {
  const [todos, dispatch] = useReducer(todoReducer, []);
  const [text, setText] = useState('');
  
  function handleSubmit(e) {
    e.preventDefault();
    dispatch({ type: 'ADD', text });
    setText('');
  }
  
  return (
    <div>
      <form onSubmit={handleSubmit}>
        <input value={text} onChange={e => setText(e.target.value)} />
        <button type="submit">Add</button>
      </form>
      <ul>
        {todos.map(todo => (
          <li
            key={todo.id}
            onClick={() => dispatch({ type: 'TOGGLE', id: todo.id })}
            style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}
          >
            {todo.text}
          </li>
        ))}
      </ul>
    </div>
  );
}

Patrones para estado complejo a nivel de componente

Cuando trabajamos con estado complejo a nivel de componente, existen patrones que pueden mejorar significativamente la organización y mantenibilidad del código:

Patrón de inicialización diferida:

function UserProfile() {
  const [user, setUser] = useState(() => {
    // Cálculo costoso o recuperación de localStorage
    const savedUser = localStorage.getItem('user');
    return savedUser ? JSON.parse(savedUser) : { name: '', preferences: {} };
  });
  
  // Resto del componente
}

Patrón de agrupación de estado relacionado:

function SearchComponent() {
  const [searchState, setSearchState] = useState({
    query: '',
    results: [],
    isLoading: false,
    error: null
  });
  
  const updateSearch = (updates) => {
    setSearchState(prev => ({ ...prev, ...updates }));
  };
  
  const handleSearch = async () => {
    updateSearch({ isLoading: true, error: null });
    try {
      const results = await fetchSearchResults(searchState.query);
      updateSearch({ results, isLoading: false });
    } catch (error) {
      updateSearch({ error, isLoading: false, results: [] });
    }
  };
  
  // Resto del componente
}

Patrón custom hook para lógica reutilizable:

function useFormField(initialValue = '') {
  const [value, setValue] = useState(initialValue);
  const [touched, setTouched] = useState(false);
  const [error, setError] = useState(null);
  
  const handleChange = (e) => {
    setValue(e.target.value);
    if (touched) validate(e.target.value);
  };
  
  const handleBlur = () => {
    setTouched(true);
    validate(value);
  };
  
  const validate = (currentValue) => {
    // Lógica de validación
    setError(currentValue ? null : 'Este campo es requerido');
  };
  
  return {
    value,
    touched,
    error,
    handleChange,
    handleBlur,
    setValue
  };
}

// Uso
function SignupForm() {
  const email = useFormField('');
  const password = useFormField('');
  
  return (
    <form>
      <div>
        <input 
          type="email"
          value={email.value}
          onChange={email.handleChange}
          onBlur={email.handleBlur}
        />
        {email.touched && email.error && <span>{email.error}</span>}
      </div>
      {/* Campo de contraseña similar */}
    </form>
  );
}

Componentes controlados vs no controlados

Una distinción fundamental en la gestión de estado en React es entre componentes controlados y no controlados, especialmente relevante para formularios e inputs.

Componentes controlados: El estado reside en el componente padre, que proporciona tanto el valor actual como los manejadores para actualizarlo.

function ControlledForm() {
  const [value, setValue] = useState('');
  
  const handleSubmit = (e) => {
    e.preventDefault();
    console.log('Submitted:', value);
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <input
        value={value}
        onChange={(e) => setValue(e.target.value)}
      />
      <button type="submit">Submit</button>
    </form>
  );
}

Componentes no controlados: El estado reside en el DOM, y se accede a él a través de refs cuando es necesario.

function UncontrolledForm() {
  const inputRef = useRef();
  
  const handleSubmit = (e) => {
    e.preventDefault();
    console.log('Submitted:', inputRef.current.value);
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <input ref={inputRef} defaultValue="" />
      <button type="submit">Submit</button>
    </form>
  );
}

Cada enfoque tiene sus ventajas y desventajas:

  • Controlados: Ofrecen control total sobre el comportamiento y validación, pero requieren más código y pueden afectar al rendimiento en formularios muy grandes.
  • No controlados: Son más simples y pueden tener mejor rendimiento, pero ofrecen menos control sobre el comportamiento momento a momento.

La elección depende del nivel de control que necesites sobre la interacción del usuario y la complejidad del formulario.

⚠️ Advertencia
Los componentes que mezclan enfoques controlados y no controlados son una fuente común de bugs. Decide un enfoque para cada componente y mantenlo consistente.

Estado compartido: Prop drilling y su evolución

Cuando un estado necesita ser compartido entre múltiples componentes, nos enfrentamos al desafío de hacerlo accesible donde se necesita, manteniendo el código limpio y eficiente.

El problema de prop drilling

El "prop drilling" ocurre cuando pasamos datos a través de múltiples niveles de componentes solo para que lleguen a un componente profundamente anidado. Este patrón emerge naturalmente en React, pero puede convertirse en un problema:

function App() {
  const [user, setUser] = useState({ name: 'Alex' });
  
  return (
    <div>
      <Header user={user} />
      <Main user={user} setUser={setUser} />
      <Footer />
    </div>
  );
}

function Main({ user, setUser }) {
  return (
    <main>
      <Sidebar user={user} />
      <Content user={user} setUser={setUser} />
    </main>
  );
}

function Content({ user, setUser }) {
  return (
    <section>
      <ProfileCard user={user} />
      <ProfileEditor user={user} setUser={setUser} />
    </section>
  );
}

// Y así sucesivamente...

Los problemas de este enfoque incluyen:

  • Verbosidad: cada componente intermedio debe declarar y pasar props que no utiliza
  • Fragilidad: cambios en la estructura de datos requieren modificaciones en múltiples componentes
  • Acoplamiento: los componentes intermedios conocen datos que no necesitan
  • Dificultad de refactorización: mover componentes en la jerarquía se vuelve complicado

Técnicas para minimizar el drilling

Existen varias técnicas para reducir el prop drilling sin recurrir inmediatamente a soluciones de estado global:

Componentes compuestos: Utilizan children y otros patrones de composición para evitar pasar props a través de componentes intermedios.

function App() {
  const [user, setUser] = useState({ name: 'Alex' });
  
  return (
    <UserContext.Provider value={{ user, setUser }}>
      <Page>
        <Header />
        <Main>
          <Sidebar />
          <Content>
            <ProfileCard />
            <ProfileEditor />
          </Content>
        </Main>
        <Footer />
      </Page>
    </UserContext.Provider>
  );
}

// Cada componente ahora puede acceder al contexto directamente si lo necesita
// sin pasar props a través de toda la jerarquía

Componentes de orden superior (HOCs): Permiten "inyectar" props relacionadas con el estado en componentes específicos sin afectar la jerarquía intermedia.

function withUser(Component) {
  return function WrappedComponent(props) {
    const { user, setUser } = useContext(UserContext);
    return <Component {...props} user={user} setUser={setUser} />;
  };
}

// Uso
const ProfileCardWithUser = withUser(ProfileCard);

Patrón "render props": Proporciona flexibilidad en la composición mientras evita el drilling.

function UserProvider({ children }) {
  const [user, setUser] = useState({ name: 'Alex' });
  
  return children({ user, setUser });
}

// Uso
<UserProvider>
  {({ user, setUser }) => (
    <ProfileEditor user={user} onUpdate={setUser} />
  )}
</UserProvider>

Composición como alternativa

La composición de componentes es una de las herramientas más poderosas de React para evitar el prop drilling. Permite estructurar la aplicación de manera que los datos y comportamientos estén disponibles exactamente donde se necesitan, sin tener que pasar por componentes intermedios.

Ejemplo de composición efectiva:

function UserProfilePage() {
  const [user, setUser] = useState(null);
  const [isEditing, setIsEditing] = useState(false);
  
  // Estos datos solo están disponibles en este componente y sus hijos directos
  // No necesitamos pasarlos a través de toda la aplicación
  
  return (
    <Page>
      <UserData user={user} onEdit={() => setIsEditing(true)} />
      {isEditing && (
        <Modal onClose={() => setIsEditing(false)}>
          <UserForm user={user} onSave={(updatedUser) => {
            setUser(updatedUser);
            setIsEditing(false);
          }} />
        </Modal>
      )}
    </Page>
  );
}

La clave de la composición efectiva es diseñar componentes con responsabilidades claras y determinar el nivel adecuado en la jerarquía para cada pieza de estado.

💡 Pro tip
Antes de alcanzar soluciones de estado global, pregúntate: ¿Podría reestructurar mis componentes para que este estado esté disponible exactamente donde se necesita sin prop drilling excesivo?

Estado compartido: Context API en profundidad

Cuando la composición y el prop drilling no son suficientes, la Context API de React ofrece una solución elegante para compartir estado entre componentes sin pasarlo explícitamente a través de props.

Diseño efectivo de Context

El diseño de un contexto efectivo va más allá de simplemente crear un provider y un consumer. Requiere considerar la estructura de los datos, la frecuencia de actualización y el ámbito de uso.

Principios para un diseño efectivo de contextos:

  1. Granularidad adecuada: Crear contextos separados para datos que cambian con diferentes frecuencias
  2. Colocación estratégica: Situar providers lo más bajo posible en el árbol de componentes
  3. API consistente: Proporcionar no solo datos sino también funciones para modificarlos
  4. Valores por defecto significativos: Diseñar para que el valor por defecto sea utilizable cuando sea posible

Ejemplo de diseño de contexto bien estructurado:

// Creación del contexto
const UserContext = createContext(null);

// Custom hook para facilitar el consumo
function useUser() {
  const context = useContext(UserContext);
  if (context === null) {
    throw new Error('useUser debe usarse dentro de un UserProvider');
  }
  return context;
}

// Provider con una API clara
function UserProvider({ children }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  
  const login = async (credentials) => {
    setLoading(true);
    setError(null);
    try {
      const userData = await authService.login(credentials);
      setUser(userData);
      return userData;
    } catch (err) {
      setError(err.message);
      throw err;
    } finally {
      setLoading(false);
    }
  };
  
  const logout = async () => {
    await authService.logout();
    setUser(null);
  };
  
  const updateProfile = async (updates) => {
    setLoading(true);
    try {
      const updated = await userService.updateProfile(user.id, updates);
      setUser(updated);
      return updated;
    } catch (err) {
      setError(err.message);
      throw err;
    } finally {
      setLoading(false);
    }
  };
  
  // Valor que proporciona el contexto
  const value = {
    user,
    loading,
    error,
    login,
    logout,
    updateProfile
  };
  
  return <UserContext.Provider value={value}>{children}</UserContext.Provider>;
}

// Exportar todo lo necesario
export { UserProvider, useUser };

Este diseño proporciona:

  • Una API clara y predecible con funciones específicas en lugar de un genérico setState
  • Manejo de estados de carga y error
  • Validación mediante el custom hook para prevenir errores comunes
  • Encapsulamiento de la lógica relacionada con el usuario

Optimización de rendimiento con Context

Un desafío común al usar Context es que todos los componentes que consumen un contexto se vuelven a renderizar cuando cualquier valor en ese contexto cambia. Esto puede llevar a renderizados innecesarios y problemas de rendimiento.

Estrategias para optimizar el rendimiento:

  1. Separar contextos por dominio funcional:
// En lugar de un único contexto global
const AppContext = createContext();

// Separar en dominios específicos
const UserContext = createContext();
const ThemeContext = createContext();
const FeatureFlagsContext = createContext();
  1. Separar estado y acciones:
// Contexto para los datos (cambia frecuentemente)
const UserDataContext = createContext();

// Contexto para las acciones (casi nunca cambia)
const UserActionsContext = createContext();

function UserProvider({ children }) {
  const [userData, setUserData] = useState(null);
  
  // Este objeto se crea una sola vez
  const actions = useMemo(() => ({
    login: async (credentials) => { /* ... */ },
    logout: async () => { /* ... */ },
    updateProfile: async (updates) => { /* ... */ }
  }), []);
  
  return (
    <UserActionsContext.Provider value={actions}>
      <UserDataContext.Provider value={userData}>
        {children}
      </UserDataContext.Provider>
    </UserActionsContext.Provider>
  );
}
  1. Memoizar valores del Provider:
function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light');
  
  // Solo se crea un nuevo objeto cuando theme cambia
  const value = useMemo(() => ({ theme, setTheme }), [theme]);
  
  return (
    <ThemeContext.Provider value={value}>
      {children}
    </ThemeContext.Provider>
  );
}
  1. Memoizar componentes consumidores:
const Header = memo(function Header() {
  const { theme } = useContext(ThemeContext);
  return <header className={`header-${theme}`}>...</header>;
});

Patrones avanzados: múltiples contexts y composición

Para aplicaciones complejas, la combinación de múltiples contextos y patrones de composición avanzados puede proporcionar soluciones elegantes.

Patrón de composición de providers:

function AppProviders({ children }) {
  return (
    <ErrorBoundary>
      <AuthProvider>
        <ThemeProvider>
          <NotificationsProvider>
            <FeatureFlagsProvider>
              {children}
            </FeatureFlagsProvider>
          </NotificationsProvider>
        </ThemeProvider>
      </AuthProvider>
    </ErrorBoundary>
  );
}

// Uso
function App() {
  return (
    <AppProviders>
      <Router>
        <Routes />
      </Router>
    </AppProviders>
  );
}

Patrón de contexto compuesto:

function CartProvider({ children }) {
  const { user } = useUser(); // Consumir otro contexto
  const [items, setItems] = useState([]);
  
  const addItem = useCallback((product, quantity = 1) => {
    setItems(current => {
      const existing = current.find(item => item.id === product.id);
      if (existing) {
        return current.map(item => 
          item.id === product.id 
            ? { ...item, quantity: item.quantity + quantity }
            : item
        );
      } else {
        return [...current, { ...product, quantity }];
      }
    });
  }, []);
  
  const removeItem = useCallback((productId) => {
    setItems(current => current.filter(item => item.id !== productId));
  }, []);
  
  // Efectos para sincronización con backend, localStorage, etc.
  useEffect(() => {
    if (user) {
      // Cargar carrito del usuario desde el backend
      fetchUserCart(user.id).then(setItems);
    } else {
      // Cargar carrito de localStorage para usuarios anónimos
      const savedCart = localStorage.getItem('cart');
      if (savedCart) setItems(JSON.parse(savedCart));
    }
  }, [user]);
  
  useEffect(() => {
    if (!user) {
      localStorage.setItem('cart', JSON.stringify(items));
    }
  }, [items, user]);
  
  const value = {
    items,
    addItem,
    removeItem,
    totalItems: items.reduce((sum, item) => sum + item.quantity, 0),
    totalPrice: items.reduce((sum, item) => sum + item.price * item.quantity, 0)
  };
  
  return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
}

Este patrón muestra cómo los contextos pueden interactuar entre sí, consumiendo datos de otros contextos y proporcionando funcionalidades compuestas.

🔍 Detalle técnico
Al componer múltiples contextos, es importante considerar el orden de anidación. Los providers más internos pueden consumir contextos de los providers externos, pero no al revés.

Estado global: Flux, Redux y alternativas modernas

Cuando las necesidades de gestión de estado superan lo que Context puede ofrecer eficientemente, es momento de considerar bibliotecas de estado global.

El patrón Flux y su evolución

El patrón Flux, introducido por Facebook, propuso un flujo de datos unidireccional que revolucionó la gestión de estado en aplicaciones frontend:

  1. Acciones: Describen eventos que ocurren en la aplicación
  2. Dispatcher: Distribuye las acciones a los stores
  3. Stores: Contienen el estado y la lógica de modificación
  4. Views: Renderizan el estado y disparan nuevas acciones

Redux emergió como la implementación más popular de este patrón, introduciendo conceptos clave:

  • Store único: Toda la aplicación comparte un único store
  • Estado inmutable: El estado no se modifica, se reemplaza
  • Reducers puros: Funciones que especifican cómo el estado cambia en respuesta a acciones

Redux moderno con hooks y toolkit

Redux ha evolucionado significativamente desde sus inicios. Redux Toolkit (RTK) simplifica muchas de las complejidades originales:

// Definición de slice con Redux Toolkit
import { createSlice, configureStore } from '@reduxjs/toolkit';

const todosSlice = createSlice({
  name: 'todos',
  initialState: [],
  reducers: {
    addTodo: (state, action) => {
      // "Mutación" permitida gracias a Immer
      state.push({
        id: Date.now(),
        text: action.payload,
        completed: false
      });
    },
    toggleTodo: (state, action) => {
      const todo = state.find(todo => todo.id === action.payload);
      if (todo) {
        todo.completed = !todo.completed;
      }
    }
  }
});

// Exportar acciones y reducer
export const { addTodo, toggleTodo } = todosSlice.actions;
export default todosSlice.reducer;

// Configuración del store
const store = configureStore({
  reducer: {
    todos: todosSlice.reducer
  }
});

// Componente consumidor con hooks
function TodoList() {
  const todos = useSelector(state => state.todos);
  const dispatch = useDispatch();
  const [text, setText] = useState('');
  
  const handleSubmit = e => {
    e.preventDefault();
    dispatch(addTodo(text));
    setText('');
  };
  
  return (
    <div>
      <form onSubmit={handleSubmit}>
        <input value={text} onChange={e => setText(e.target.value)} />
        <button type="submit">Add</button>
      </form>
      <ul>
        {todos.map(todo => (
          <li
            key={todo.id}
            onClick={() => dispatch(toggleTodo(todo.id))}
            style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}
          >
            {todo.text}
          </li>
        ))}
      </ul>
    </div>
  );
}

Las ventajas de Redux moderno con RTK incluyen:

  • Menos boilerplate: createSlice genera acciones y reducers automáticamente
  • Inmutabilidad simplificada: Immer permite un estilo de código más intuitivo
  • Integración con TypeScript: Mejores tipos y inferencia automática
  • DevTools integradas: Herramientas de depuración potentes
  • Middleware pre-configurado: Thunk para lógica asíncrona

Alternativas emergentes: Zustand, Jotai, Recoil

El ecosistema de gestión de estado en React ha florecido con alternativas que ofrecen enfoques más simples pero potentes:

Zustand: Minimalista pero potente, con API basada en hooks:

import create from 'zustand';

// Crear un store
const useTodoStore = create((set) => ({
  todos: [],
  addTodo: (text) => set((state) => ({
    todos: [...state.todos, { id: Date.now(), text, completed: false }]
  })),
  toggleTodo: (id) => set((state) => ({
    todos: state.todos.map(todo =>
      todo.id === id ? { ...todo, completed: !todo.completed } : todo
    )
  }))
}));

// Uso en componente
function TodoList() {
  const todos = useTodoStore((state) => state.todos);
  const addTodo = useTodoStore((state) => state.addTodo);
  const toggleTodo = useTodoStore((state) => state.toggleTodo);
  const [text, setText] = useState('');
  
  const handleSubmit = e => {
    e.preventDefault();
    addTodo(text);
    setText('');
  };
  
  return (
    // UI similar al ejemplo anterior
  );
}

Jotai: Enfoque atómico inspirado en React Recoil:

import { atom, useAtom } from 'jotai';

// Definir átomos
const todosAtom = atom([]);

const filteredTodosAtom = atom(
  (get) => {
    const todos = get(todosAtom);
    const filter = get(filterAtom);
    
    switch (filter) {
      case 'completed':
        return todos.filter(todo => todo.completed);
      case 'active':
        return todos.filter(todo => !todo.completed);
      default:
        return todos;
    }
  }
);

// Uso en componentes
function TodoList() {
  const [todos, setTodos] = useAtom(todosAtom);
  const [filteredTodos] = useAtom(filteredTodosAtom);
  
  const addTodo = (text) => {
    setTodos(prev => [...prev, { id: Date.now(), text, completed: false }]);
  };
  
  // Resto del componente
}