2.4 — Gerenciamento de Estado: Stores vs Context/Redux

Como compartilhar estado entre componentes sem enlouquecer.

Objetivos da Aula

  • Entender o problema de estado global
  • Comparar Context API, Redux/Zustand com Svelte Stores
  • Ver como Svelte simplifica drasticamente o gerenciamento de estado

O Problema: Prop Drilling

Prop Drilling
App tem o estado user
└── Layout passa user
└── Sidebar passa user
└── UserMenu passa user
└── Avatar finalmente usa user! ✓
4 componentes intermediarios so passando props!

Problema: 4 componentes intermediarios so passando props!


React: Context API

Criando um Context

// contexts/UserContext.jsx
import { createContext, useContext, useState } from 'react'

// Criar o contexto
const UserContext = createContext(null)

// Provider component
export function UserProvider({ children }) {
  const [user, setUser] = useState(null)

  const login = async (credentials) => {
    const response = await fetch('/api/login', {
      method: 'POST',
      body: JSON.stringify(credentials)
    })
    const userData = await response.json()
    setUser(userData)
  }

  const logout = () => setUser(null)

  // Valor do contexto
  const value = {
    user,
    login,
    logout,
    isAuthenticated: !!user
  }

  return (
    <UserContext.Provider value={value}>
      {children}
    </UserContext.Provider>
  )
}

// Hook customizado para consumir
export function useUser() {
  const context = useContext(UserContext)
  if (!context) {
    throw new Error('useUser must be used within UserProvider')
  }
  return context
}

Usando o Context

// App.jsx
import { UserProvider } from './contexts/UserContext'

function App() {
  return (
    <UserProvider>
      <Layout />
    </UserProvider>
  )
}

// Avatar.jsx (componente profundamente aninhado)
import { useUser } from './contexts/UserContext'

function Avatar() {
  const { user, logout } = useUser()

  return (
    <div>
      <img src={user?.avatar} alt={user?.name} />
      <button onClick={logout}>Sair</button>
    </div>
  )
}

Problemas do Context

// ❌ Problema 1: Re-renders desnecessários
// Qualquer mudança no contexto re-renderiza TODOS os consumidores

const value = {
  user,      // Mudou user?
  settings,  // Todos que usam settings também re-renderizam!
  theme,     // Todos que usam theme também!
}

// ❌ Problema 2: Múltiplos providers aninhados (Provider Hell)
function App() {
  return (
    <AuthProvider>
      <ThemeProvider>
        <CartProvider>
          <NotificationProvider>
            <ToastProvider>
              <ModalProvider>
                <Layout />
              </ModalProvider>
            </ToastProvider>
          </NotificationProvider>
        </CartProvider>
      </ThemeProvider>
    </AuthProvider>
  )
}

React: Redux/Zustand

Redux (verboso)

// store/userSlice.js
import { createSlice } from '@reduxjs/toolkit'

const userSlice = createSlice({
  name: 'user',
  initialState: { data: null, loading: false, error: null },
  reducers: {
    loginStart: (state) => {
      state.loading = true
    },
    loginSuccess: (state, action) => {
      state.data = action.payload
      state.loading = false
    },
    loginError: (state, action) => {
      state.error = action.payload
      state.loading = false
    },
    logout: (state) => {
      state.data = null
    }
  }
})

export const { loginStart, loginSuccess, loginError, logout } = userSlice.actions
export default userSlice.reducer

// store/index.js
import { configureStore } from '@reduxjs/toolkit'
import userReducer from './userSlice'

export const store = configureStore({
  reducer: {
    user: userReducer
  }
})

// Componente
import { useSelector, useDispatch } from 'react-redux'
import { loginSuccess, logout } from './store/userSlice'

function Avatar() {
  const user = useSelector(state => state.user.data)
  const dispatch = useDispatch()

  return (
    <div>
      <img src={user?.avatar} />
      <button onClick={() => dispatch(logout())}>Sair</button>
    </div>
  )
}

Zustand (mais simples)

// stores/userStore.js
import { create } from 'zustand'

const useUserStore = create((set) => ({
  user: null,
  loading: false,

  login: async (credentials) => {
    set({ loading: true })
    const response = await fetch('/api/login', {
      method: 'POST',
      body: JSON.stringify(credentials)
    })
    const user = await response.json()
    set({ user, loading: false })
  },

  logout: () => set({ user: null })
}))

export default useUserStore

// Componente
import useUserStore from './stores/userStore'

function Avatar() {
  const user = useUserStore(state => state.user)
  const logout = useUserStore(state => state.logout)

  return (
    <div>
      <img src={user?.avatar} />
      <button onClick={logout}>Sair</button>
    </div>
  )
}

Svelte: Stores Nativos

Stores Built-in

// stores/user.js
import { writable } from 'svelte/store'

// Criar store (muito simples!)
export const user = writable(null)

// Métodos customizados
function createUserStore() {
  const { subscribe, set, update } = writable(null)

  return {
    subscribe, // Obrigatório para ser um store

    login: async (credentials) => {
      const response = await fetch('/api/login', {
        method: 'POST',
        body: JSON.stringify(credentials)
      })
      const userData = await response.json()
      set(userData)
    },

    logout: () => set(null),

    updateProfile: (data) => update(user => ({ ...user, ...data }))
  }
}

export const userStore = createUserStore()

Usando Stores em Componentes

<!-- Avatar.svelte -->
<script>
  import { userStore } from './stores/user.js'

  // $ prefix = auto-subscribe! ✨
  // Sem boilerplate, sem hooks, sem providers
</script>

<div>
  <img src={$userStore?.avatar} alt={$userStore?.name} />
  <button on:click={userStore.logout}>Sair</button>
</div>

Isso é tudo. Sem providers, sem hooks, sem boilerplate.


Comparação de Boilerplate

React Context: ~50 linhas

// context + provider + hook + uso = ~50 linhas
const UserContext = createContext(null)

export function UserProvider({ children }) {
  const [user, setUser] = useState(null)
  const login = async (cred) => { /* ... */ }
  const logout = () => setUser(null)

  return (
    <UserContext.Provider value={{ user, login, logout }}>
      {children}
    </UserContext.Provider>
  )
}

export function useUser() {
  const ctx = useContext(UserContext)
  if (!ctx) throw new Error('...')
  return ctx
}

// App.jsx
<UserProvider><App /></UserProvider>

// Component.jsx
const { user, logout } = useUser()

Svelte Store: ~15 linhas

// stores/user.js
import { writable } from 'svelte/store'

function createUserStore() {
  const { subscribe, set } = writable(null)
  return {
    subscribe,
    login: async (cred) => { /* ... */ set(user) },
    logout: () => set(null)
  }
}
export const userStore = createUserStore()
<!-- Component.svelte -->
<script>
  import { userStore } from './stores/user'
</script>
<span>{$userStore?.name}</span>

Tipos de Stores

writable — Estado mutável

import { writable } from 'svelte/store'

// Simples
const count = writable(0)
count.set(5)        // Define valor
count.update(n => n + 1) // Atualiza baseado no atual

// Com valor inicial complexo
const settings = writable({
  theme: 'dark',
  language: 'pt-BR',
  notifications: true
})

readable — Estado somente leitura

import { readable } from 'svelte/store'

// Hora atual (atualiza a cada segundo)
const time = readable(new Date(), (set) => {
  const interval = setInterval(() => {
    set(new Date())
  }, 1000)

  // Cleanup quando último subscriber sair
  return () => clearInterval(interval)
})

// Posição do mouse
const mousePosition = readable({ x: 0, y: 0 }, (set) => {
  const handler = (e) => set({ x: e.clientX, y: e.clientY })
  window.addEventListener('mousemove', handler)
  return () => window.removeEventListener('mousemove', handler)
})

derived — Valores computados

import { writable, derived } from 'svelte/store'

const items = writable([
  { name: 'Maçã', price: 2, qty: 3 },
  { name: 'Banana', price: 1, qty: 5 }
])

// Derivado de um store
const total = derived(items, $items =>
  $items.reduce((sum, item) => sum + item.price * item.qty, 0)
)

// Derivado de múltiplos stores
const user = writable({ name: 'João' })
const greeting = derived([user, time], ([$user, $time]) =>
  `Olá ${$user.name}! São ${$time.toLocaleTimeString()}`
)

Equivalências React → Svelte

ReactSvelte
useStatelet ou writable
useContext$store (auto-subscribe)
useReducerwritable + update function
useMemo com depsderived store
Redux storewritable + custom methods
Zustandwritable + custom methods
Jotai atomswritable stores
Recoil atomswritable stores

Store Avançado: Carrinho de Compras

React + Context

// CartContext.jsx
const CartContext = createContext()

export function CartProvider({ children }) {
  const [items, setItems] = useState([])

  const addItem = (product) => {
    setItems(prev => {
      const existing = prev.find(i => i.id === product.id)
      if (existing) {
        return prev.map(i =>
          i.id === product.id
            ? { ...i, qty: i.qty + 1 }
            : i
        )
      }
      return [...prev, { ...product, qty: 1 }]
    })
  }

  const removeItem = (id) => {
    setItems(prev => prev.filter(i => i.id !== id))
  }

  const updateQty = (id, qty) => {
    if (qty <= 0) {
      removeItem(id)
      return
    }
    setItems(prev => prev.map(i =>
      i.id === id ? { ...i, qty } : i
    ))
  }

  const clear = () => setItems([])

  const total = useMemo(() =>
    items.reduce((sum, i) => sum + i.price * i.qty, 0),
    [items]
  )

  const itemCount = useMemo(() =>
    items.reduce((sum, i) => sum + i.qty, 0),
    [items]
  )

  return (
    <CartContext.Provider value={{
      items, addItem, removeItem, updateQty, clear, total, itemCount
    }}>
      {children}
    </CartContext.Provider>
  )
}

export const useCart = () => useContext(CartContext)

Svelte Store

// stores/cart.js
import { writable, derived } from 'svelte/store'

function createCartStore() {
  const { subscribe, set, update } = writable([])

  return {
    subscribe,

    addItem: (product) => update(items => {
      const existing = items.find(i => i.id === product.id)
      if (existing) {
        return items.map(i =>
          i.id === product.id
            ? { ...i, qty: i.qty + 1 }
            : i
        )
      }
      return [...items, { ...product, qty: 1 }]
    }),

    removeItem: (id) => update(items =>
      items.filter(i => i.id !== id)
    ),

    updateQty: (id, qty) => update(items => {
      if (qty <= 0) return items.filter(i => i.id !== id)
      return items.map(i => i.id === id ? { ...i, qty } : i)
    }),

    clear: () => set([])
  }
}

export const cart = createCartStore()

// Stores derivados (calculados automaticamente)
export const cartTotal = derived(cart, $cart =>
  $cart.reduce((sum, i) => sum + i.price * i.qty, 0)
)

export const cartCount = derived(cart, $cart =>
  $cart.reduce((sum, i) => sum + i.qty, 0)
)

Uso

<!-- CartIcon.svelte -->
<script>
  import { cartCount } from './stores/cart'
</script>

<div class="cart-icon">
  🛒
  {#if $cartCount > 0}
    <span class="badge">{$cartCount}</span>
  {/if}
</div>

<!-- CartSummary.svelte -->
<script>
  import { cart, cartTotal } from './stores/cart'
</script>

<div class="cart">
  {#each $cart as item (item.id)}
    <div class="item">
      <span>{item.name}</span>
      <span>x{item.qty}</span>
      <button on:click={() => cart.removeItem(item.id)}>×</button>
    </div>
  {/each}

  <div class="total">
    Total: R$ {$cartTotal.toFixed(2)}
  </div>

  <button on:click={cart.clear}>Limpar</button>
</div>

Persistência em localStorage

Svelte (simples!)

// stores/persisted.js
import { writable } from 'svelte/store'

function createPersistedStore(key, initialValue) {
  // Tenta carregar do localStorage
  const stored = localStorage.getItem(key)
  const initial = stored ? JSON.parse(stored) : initialValue

  const store = writable(initial)

  // Salva no localStorage quando mudar
  store.subscribe(value => {
    localStorage.setItem(key, JSON.stringify(value))
  })

  return store
}

// Uso
export const settings = createPersistedStore('settings', {
  theme: 'light',
  language: 'pt-BR'
})

export const cart = createPersistedStore('cart', [])

✅ Desafio da Aula

Objetivo

Criar um sistema de temas (dark/light) com Svelte stores.

Requisitos

  1. Store theme que guarda ‘light’ ou ‘dark’
  2. Store derivado isDark que retorna boolean
  3. Função toggle() para alternar
  4. Persistir no localStorage
  5. Componente ThemeToggle que usa o store

Spec de Verificação

  • Tema inicial carrega do localStorage (ou ‘light’ se não existir)
  • Clicar no toggle alterna entre light/dark
  • Recarregar página mantém o tema escolhido
  • $isDark reflete corretamente o tema atual

Solução

🔍 Clique para ver a solução
// stores/theme.js
import { writable, derived } from 'svelte/store'
import { browser } from '$app/environment' // SvelteKit
// ou: const browser = typeof window !== 'undefined'

function createThemeStore() {
  // Valor inicial do localStorage ou 'light'
  const initial = browser
    ? localStorage.getItem('theme') || 'light'
    : 'light'

  const { subscribe, set, update } = writable(initial)

  // Persiste mudanças
  if (browser) {
    subscribe(value => {
      localStorage.setItem('theme', value)
      // Opcional: aplica classe no body
      document.body.classList.toggle('dark', value === 'dark')
    })
  }

  return {
    subscribe,
    set,
    toggle: () => update(t => t === 'light' ? 'dark' : 'light'),
    setLight: () => set('light'),
    setDark: () => set('dark')
  }
}

export const theme = createThemeStore()
export const isDark = derived(theme, $theme => $theme === 'dark')
<!-- ThemeToggle.svelte -->
<script>
  import { theme, isDark } from './stores/theme'
</script>

<button
  on:click={theme.toggle}
  class="theme-toggle"
  aria-label="Alternar tema"
>
  {$isDark ? '🌙' : '☀️'}
</button>

<style>
  .theme-toggle {
    font-size: 1.5rem;
    background: none;
    border: none;
    cursor: pointer;
  }
</style>

🧪 Exercício Interativo

Pratique o que aprendeu com o exercício interativo!

📁 Local: exercicios/modulo-02/exercicio-2.4/

cd ../../exercicios/modulo-02/exercicio-2.4
npm install
npm test

No exercício você vai implementar:

  • createThemeStore() - Store de tema com subscribe, set e toggle
  • derived() - Função para criar stores derivados

Todos os testes passando = aula concluída com sucesso!


Próxima aula: 2.5 — Estilização: CSS com Escopo vs CSS-in-JS