🚀 Oferta especial: 60% OFF no CrazyStack - Últimas vagas!Garantir vaga →
Voltar ao Curso
MÓDULO 2
AULA 5

Zustand + Atomic Design

Gerenciamento de estado global com Zustand e organização de componentes usando a metodologia Atomic Design.

45 min
Estado Global
Atomic Design
⚛️ Atomic Design + Zustand

Vamos combinar o Zustand para estado global com a metodologia Atomic Design para criar uma arquitetura de componentes escalável.

Atomic Design

  • Atoms: Componentes básicos (Button, Input)
  • Molecules: Grupos de atoms (SearchBox)
  • Organisms: Seções complexas (Header)
  • Templates: Layouts de página
  • Pages: Instâncias específicas

Zustand

  • Simples: API minimalista
  • TypeScript: Suporte nativo
  • Performance: Re-renders otimizados
  • DevTools: Debugging fácil
  • Persistência: localStorage automático
Estrutura Atomic Design

Nova estrutura de componentes

src/components/
├── atoms/                    # Componentes básicos
│   ├── Button/
│   │   ├── Button.tsx
│   │   ├── Button.stories.tsx
│   │   └── index.ts
│   ├── Input/
│   ├── Label/
│   ├── Badge/
│   ├── Avatar/
│   └── Icon/
├── molecules/               # Combinações de atoms
│   ├── SearchBox/
│   │   ├── SearchBox.tsx
│   │   └── index.ts
│   ├── FormField/
│   ├── MetricCard/
│   ├── OrderStatus/
│   └── UserMenu/
├── organisms/              # Seções complexas
│   ├── Header/
│   │   ├── Header.tsx
│   │   └── index.ts
│   ├── Sidebar/
│   ├── OrderTable/
│   ├── DashboardMetrics/
│   └── LoginForm/
├── templates/              # Layouts de página
│   ├── DashboardLayout/
│   ├── AuthLayout/
│   └── PublicLayout/
└── pages/                  # Páginas específicas
    ├── DashboardPage/
    ├── OrdersPage/
    └── LoginPage/
Configuração do Zustand

src/store/auth-store.ts (atualizado)

import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
import { devtools } from 'zustand/middleware'
import { api } from '@/lib/api'
import type { User, Restaurant } from '@/types/auth'

interface AuthState {
  // State
  user: User | null
  restaurant: Restaurant | null
  isLoading: boolean
  isAuthenticated: boolean
  
  // Actions
  sendMagicLink: (email: string) => Promise<void>
  authenticateFromLink: (code: string) => Promise<void>
  logout: () => Promise<void>
  loadUser: () => Promise<void>
  setLoading: (loading: boolean) => void
  
  // Computed
  isManager: () => boolean
  hasRestaurant: () => boolean
}

export const useAuthStore = create<AuthState>()(
  devtools(
    persist(
      (set, get) => ({
        // Initial State
        user: null,
        restaurant: null,
        isLoading: false,
        isAuthenticated: false,

        // Actions
        setLoading: (loading: boolean) => {
          set({ isLoading: loading }, false, 'auth/setLoading')
        },

        sendMagicLink: async (email: string) => {
          try {
            set({ isLoading: true }, false, 'auth/sendMagicLink/start')
            await api.sendAuthLink(email)
          } catch (error) {
            console.error('Erro ao enviar magic link:', error)
            throw error
          } finally {
            set({ isLoading: false }, false, 'auth/sendMagicLink/end')
          }
        },

        authenticateFromLink: async (code: string) => {
          try {
            set({ isLoading: true }, false, 'auth/authenticate/start')
            
            const response = await api.authenticateFromLink(code)
            api.setToken(response.token)
            
            const profile = await api.getProfile()
            
            set({
              user: profile.user,
              restaurant: profile.restaurant,
              isAuthenticated: true,
              isLoading: false,
            }, false, 'auth/authenticate/success')
          } catch (error) {
            console.error('Erro na autenticação:', error)
            set({ isLoading: false }, false, 'auth/authenticate/error')
            throw error
          }
        },

        loadUser: async () => {
          try {
            set({ isLoading: true }, false, 'auth/loadUser/start')
            
            const profile = await api.getProfile()
            
            set({
              user: profile.user,
              restaurant: profile.restaurant,
              isAuthenticated: true,
              isLoading: false,
            }, false, 'auth/loadUser/success')
          } catch (error) {
            api.clearToken()
            set({
              user: null,
              restaurant: null,
              isAuthenticated: false,
              isLoading: false,
            }, false, 'auth/loadUser/error')
          }
        },

        logout: async () => {
          try {
            await api.signOut()
          } catch (error) {
            console.error('Erro ao fazer logout:', error)
          } finally {
            api.clearToken()
            set({
              user: null,
              restaurant: null,
              isAuthenticated: false,
              isLoading: false,
            }, false, 'auth/logout')
          }
        },

        // Computed values
        isManager: () => {
          const { user } = get()
          return user?.role === 'manager'
        },

        hasRestaurant: () => {
          const { restaurant } = get()
          return !!restaurant
        },
      }),
      {
        name: 'auth-storage',
        storage: createJSONStorage(() => localStorage),
        partialize: (state) => ({
          user: state.user,
          restaurant: state.restaurant,
          isAuthenticated: state.isAuthenticated,
        }),
      }
    ),
    {
      name: 'auth-store',
    }
  )
)
Store de UI Global

src/store/ui-store.ts

import { create } from 'zustand'
import { devtools } from 'zustand/middleware'

interface Toast {
  id: string
  title: string
  description?: string
  type: 'success' | 'error' | 'warning' | 'info'
  duration?: number
}

interface Modal {
  id: string
  component: React.ComponentType<any>
  props?: Record<string, any>
}

interface UIState {
  // Sidebar
  sidebarOpen: boolean
  sidebarCollapsed: boolean
  
  // Theme
  theme: 'light' | 'dark' | 'system'
  
  // Toasts
  toasts: Toast[]
  
  // Modals
  modals: Modal[]
  
  // Loading states
  globalLoading: boolean
  
  // Actions
  toggleSidebar: () => void
  collapseSidebar: (collapsed: boolean) => void
  setTheme: (theme: 'light' | 'dark' | 'system') => void
  addToast: (toast: Omit<Toast, 'id'>) => void
  removeToast: (id: string) => void
  openModal: (modal: Omit<Modal, 'id'>) => void
  closeModal: (id: string) => void
  setGlobalLoading: (loading: boolean) => void
}

export const useUIStore = create<UIState>()(
  devtools(
    (set, get) => ({
      // Initial State
      sidebarOpen: true,
      sidebarCollapsed: false,
      theme: 'system',
      toasts: [],
      modals: [],
      globalLoading: false,

      // Sidebar Actions
      toggleSidebar: () => {
        set(
          (state) => ({ sidebarOpen: !state.sidebarOpen }),
          false,
          'ui/toggleSidebar'
        )
      },

      collapseSidebar: (collapsed: boolean) => {
        set(
          { sidebarCollapsed: collapsed },
          false,
          'ui/collapseSidebar'
        )
      },

      // Theme Actions
      setTheme: (theme: 'light' | 'dark' | 'system') => {
        set({ theme }, false, 'ui/setTheme')
        
        // Apply theme to document
        if (typeof window !== 'undefined') {
          const root = window.document.documentElement
          
          if (theme === 'dark') {
            root.classList.add('dark')
          } else if (theme === 'light') {
            root.classList.remove('dark')
          } else {
            // System theme
            const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches
            if (systemTheme) {
              root.classList.add('dark')
            } else {
              root.classList.remove('dark')
            }
          }
        }
      },

      // Toast Actions
      addToast: (toast: Omit<Toast, 'id'>) => {
        const id = Math.random().toString(36).substr(2, 9)
        const newToast = { ...toast, id }
        
        set(
          (state) => ({ toasts: [...state.toasts, newToast] }),
          false,
          'ui/addToast'
        )

        // Auto remove toast
        const duration = toast.duration || 5000
        setTimeout(() => {
          get().removeToast(id)
        }, duration)
      },

      removeToast: (id: string) => {
        set(
          (state) => ({ toasts: state.toasts.filter(t => t.id !== id) }),
          false,
          'ui/removeToast'
        )
      },

      // Modal Actions
      openModal: (modal: Omit<Modal, 'id'>) => {
        const id = Math.random().toString(36).substr(2, 9)
        const newModal = { ...modal, id }
        
        set(
          (state) => ({ modals: [...state.modals, newModal] }),
          false,
          'ui/openModal'
        )
      },

      closeModal: (id: string) => {
        set(
          (state) => ({ modals: state.modals.filter(m => m.id !== id) }),
          false,
          'ui/closeModal'
        )
      },

      // Loading Actions
      setGlobalLoading: (loading: boolean) => {
        set({ globalLoading: loading }, false, 'ui/setGlobalLoading')
      },
    }),
    {
      name: 'ui-store',
    }
  )
)
Atoms com Estado Global

src/components/atoms/ThemeToggle/ThemeToggle.tsx

'use client'

import { Moon, Sun, Monitor } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useUIStore } from '@/store/ui-store'

export function ThemeToggle() {
  const { theme, setTheme } = useUIStore()

  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <Button variant="ghost" size="icon">
          <Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
          <Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
          <span className="sr-only">Toggle theme</span>
        </Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent align="end">
        <DropdownMenuItem onClick={() => setTheme('light')}>
          <Sun className="mr-2 h-4 w-4" />
          <span>Light</span>
        </DropdownMenuItem>
        <DropdownMenuItem onClick={() => setTheme('dark')}>
          <Moon className="mr-2 h-4 w-4" />
          <span>Dark</span>
        </DropdownMenuItem>
        <DropdownMenuItem onClick={() => setTheme('system')}>
          <Monitor className="mr-2 h-4 w-4" />
          <span>System</span>
        </DropdownMenuItem>
      </DropdownMenuContent>
    </DropdownMenu>
  )
}

src/components/atoms/Toast/Toast.tsx

'use client'

import { useEffect } from 'react'
import { X, CheckCircle, AlertCircle, AlertTriangle, Info } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useUIStore } from '@/store/ui-store'

interface ToastProps {
  id: string
  title: string
  description?: string
  type: 'success' | 'error' | 'warning' | 'info'
}

const icons = {
  success: CheckCircle,
  error: AlertCircle,
  warning: AlertTriangle,
  info: Info,
}

const styles = {
  success: 'bg-green-50 border-green-200 text-green-800',
  error: 'bg-red-50 border-red-200 text-red-800',
  warning: 'bg-yellow-50 border-yellow-200 text-yellow-800',
  info: 'bg-blue-50 border-blue-200 text-blue-800',
}

export function Toast({ id, title, description, type }: ToastProps) {
  const { removeToast } = useUIStore()
  const Icon = icons[type]

  return (
    <div
      className={cn(
        'flex items-start space-x-3 p-4 border rounded-lg shadow-lg',
        styles[type]
      )}
    >
      <Icon className="h-5 w-5 mt-0.5 flex-shrink-0" />
      <div className="flex-1 min-w-0">
        <p className="font-medium">{title}</p>
        {description && (
          <p className="text-sm opacity-90 mt-1">{description}</p>
        )}
      </div>
      <button
        onClick={() => removeToast(id)}
        className="flex-shrink-0 opacity-70 hover:opacity-100"
      >
        <X className="h-4 w-4" />
      </button>
    </div>
  )
}

export function ToastContainer() {
  const { toasts } = useUIStore()

  return (
    <div className="fixed top-4 right-4 z-50 space-y-2 max-w-sm">
      {toasts.map((toast) => (
        <Toast key={toast.id} {...toast} />
      ))}
    </div>
  )
}
Molecules com Estado

src/components/molecules/UserMenu/UserMenu.tsx

'use client'

import { LogOut, Settings, User } from 'lucide-react'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useAuthStore } from '@/store/auth-store'
import { useUIStore } from '@/store/ui-store'

export function UserMenu() {
  const { user, logout } = useAuthStore()
  const { addToast } = useUIStore()

  if (!user) return null

  const handleLogout = async () => {
    try {
      await logout()
      addToast({
        title: 'Logout realizado',
        description: 'Você foi desconectado com sucesso.',
        type: 'success',
      })
    } catch (error) {
      addToast({
        title: 'Erro no logout',
        description: 'Ocorreu um erro ao desconectar.',
        type: 'error',
      })
    }
  }

  const getInitials = (name: string) => {
    return name
      .split(' ')
      .map(n => n[0])
      .join('')
      .toUpperCase()
      .slice(0, 2)
  }

  return (
    <DropdownMenu>
      <DropdownMenuTrigger className="flex items-center space-x-2 hover:bg-gray-100 rounded-lg p-2 transition-colors">
        <Avatar className="h-8 w-8">
          <AvatarImage src={user.avatar} alt={user.name} />
          <AvatarFallback>{getInitials(user.name)}</AvatarFallback>
        </Avatar>
        <div className="hidden md:block text-left">
          <p className="text-sm font-medium text-gray-900">{user.name}</p>
          <p className="text-xs text-gray-500">{user.email}</p>
        </div>
      </DropdownMenuTrigger>
      
      <DropdownMenuContent className="w-56" align="end">
        <DropdownMenuLabel>
          <div className="flex flex-col space-y-1">
            <p className="text-sm font-medium">{user.name}</p>
            <p className="text-xs text-muted-foreground">{user.email}</p>
          </div>
        </DropdownMenuLabel>
        
        <DropdownMenuSeparator />
        
        <DropdownMenuItem>
          <User className="mr-2 h-4 w-4" />
          <span>Perfil</span>
        </DropdownMenuItem>
        
        <DropdownMenuItem>
          <Settings className="mr-2 h-4 w-4" />
          <span>Configurações</span>
        </DropdownMenuItem>
        
        <DropdownMenuSeparator />
        
        <DropdownMenuItem onClick={handleLogout}>
          <LogOut className="mr-2 h-4 w-4" />
          <span>Sair</span>
        </DropdownMenuItem>
      </DropdownMenuContent>
    </DropdownMenu>
  )
}

src/components/molecules/MetricCard/MetricCard.tsx

import { LucideIcon } from 'lucide-react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { cn } from '@/lib/utils'

interface MetricCardProps {
  title: string
  value: string | number
  description?: string
  icon: LucideIcon
  trend?: {
    value: number
    label: string
    type: 'positive' | 'negative' | 'neutral'
  }
  className?: string
}

export function MetricCard({
  title,
  value,
  description,
  icon: Icon,
  trend,
  className,
}: MetricCardProps) {
  const trendColors = {
    positive: 'bg-green-100 text-green-800 border-green-200',
    negative: 'bg-red-100 text-red-800 border-red-200',
    neutral: 'bg-gray-100 text-gray-800 border-gray-200',
  }

  return (
    <Card className={cn('', className)}>
      <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
        <CardTitle className="text-sm font-medium text-muted-foreground">
          {title}
        </CardTitle>
        <Icon className="h-4 w-4 text-muted-foreground" />
      </CardHeader>
      <CardContent>
        <div className="text-2xl font-bold">{value}</div>
        {description && (
          <p className="text-xs text-muted-foreground mt-1">
            {description}
          </p>
        )}
        {trend && (
          <div className="flex items-center mt-2">
            <Badge
              variant="outline"
              className={cn('text-xs', trendColors[trend.type])}
            >
              {trend.type === 'positive' && '+'}
              {trend.value}% {trend.label}
            </Badge>
          </div>
        )}
      </CardContent>
    </Card>
  )
}
Hook Personalizado para Toast

src/hooks/use-toast.ts

import { useUIStore } from '@/store/ui-store'

export function useToast() {
  const { addToast } = useUIStore()

  const toast = {
    success: (title: string, description?: string) => {
      addToast({ title, description, type: 'success' })
    },
    
    error: (title: string, description?: string) => {
      addToast({ title, description, type: 'error' })
    },
    
    warning: (title: string, description?: string) => {
      addToast({ title, description, type: 'warning' })
    },
    
    info: (title: string, description?: string) => {
      addToast({ title, description, type: 'info' })
    },
  }

  return { toast }
}

// Hook para operações assíncronas com toast
export function useAsyncToast() {
  const { toast } = useToast()

  const executeWithToast = async <T>(
    operation: () => Promise<T>,
    messages: {
      loading?: string
      success?: string
      error?: string
    } = {}
  ): Promise<T> => {
    try {
      if (messages.loading) {
        toast.info('Carregando...', messages.loading)
      }

      const result = await operation()

      if (messages.success) {
        toast.success('Sucesso!', messages.success)
      }

      return result
    } catch (error) {
      const errorMessage = error instanceof Error ? error.message : 'Erro desconhecido'
      toast.error('Erro!', messages.error || errorMessage)
      throw error
    }
  }

  return { executeWithToast, toast }
}
🎯 Exercício Prático

Implemente o Zustand com Atomic Design:

1. Reorganizar componentes

Reorganize os componentes seguindo Atomic Design:

# Criar estrutura Atomic Design
mkdir -p src/components/{atoms,molecules,organisms,templates,pages}
mkdir -p src/components/atoms/{Button,Input,Badge,Avatar,Icon}
mkdir -p src/components/molecules/{SearchBox,MetricCard,UserMenu}
mkdir -p src/components/organisms/{Header,Sidebar,OrderTable}

2. Implementar stores

Crie os stores do Zustand:

  • • Store de autenticação com persistência
  • • Store de UI com tema e toasts
  • • Store de pedidos (próxima aula)
  • • DevTools configurado

3. Testar sistema de toast

Implemente e teste o sistema de notificações:

  • • Componente Toast funcional
  • • Hook useToast personalizado
  • • Auto-dismiss configurável
  • • Diferentes tipos de toast

💡 Dicas de Atomic Design:

  • • Atoms devem ser completamente reutilizáveis
  • • Molecules combinam atoms com propósito específico
  • • Organisms são seções complexas da interface
  • • Use Storybook para documentar componentes