O que significa “fluxo de login/logout” em uma SPA
Em uma SPA (Single Page Application), login e logout não são apenas ações de enviar um formulário e apagar um token. Eles formam um fluxo completo que envolve: (1) autenticar o usuário e receber credenciais (normalmente um JWT e, muitas vezes, um refresh token), (2) persistir a sessão para sobreviver a recarregamentos de página, (3) restaurar a sessão ao iniciar a aplicação, (4) sincronizar a UI com o estado real (carregando, autenticado, não autenticado), (5) lidar com expiração e invalidação de tokens e (6) garantir que múltiplas abas do navegador reflitam o mesmo estado (por exemplo, logout em uma aba deve refletir nas outras).
“Persistência de sessão” significa que, ao recarregar a página, o usuário não deve ser jogado para fora sem necessidade. Já “sincronização de UI” significa que a interface deve reagir corretamente a cada transição: mostrar loading enquanto valida a sessão, esconder áreas protegidas quando não há autenticação, atualizar menus, avatar e permissões, e evitar flashes de conteúdo privado antes da validação.
Estados essenciais para evitar UI inconsistente
Para sincronizar bem a UI, é útil pensar em estados explícitos (mesmo que você já tenha um Context/hook de autenticação):
status: 'checking': a aplicação está verificando se existe sessão persistida e se ela é válida (por exemplo, validando token, buscando /me, tentando refresh).
status: 'authenticated': há usuário carregado e tokens válidos (ou ao menos utilizáveis).
Continue em nosso aplicativo
Você poderá ouvir o audiobook com a tela desligada, ganhar gratuitamente o certificado deste curso e ainda ter acesso a outros 5.000 cursos online gratuitos.
ou continue lendo abaixo...Baixar o aplicativo
status: 'unauthenticated': não há sessão ou ela foi invalidada.
status: 'error' (opcional): falha inesperada ao restaurar sessão (rede, servidor indisponível). Em muitos casos, você degrada para 'unauthenticated' e exibe um aviso.
Além do status, normalmente você mantém: user (perfil), accessToken (curta duração), refreshToken (longa duração, idealmente em cookie httpOnly), e flags de UI (ex.: isLoggingIn, isLoggingOut). Mesmo que o token esteja presente, a UI não deve assumir autenticação sem validar o usuário, porque tokens podem estar expirados, revogados ou pertencer a outro ambiente.
Onde persistir a sessão: trade-offs práticos
Você pode persistir a sessão de diferentes formas. A escolha impacta segurança e experiência:
Cookie httpOnly + refresh token: abordagem mais segura para refresh token, pois JavaScript não acessa o cookie. O access token pode ficar em memória (melhor) e ser renovado via endpoint de refresh. Exige backend configurado com cookies, CORS e CSRF (quando aplicável).

localStorage/sessionStorage: simples, mas mais exposto a XSS. Se usar, minimize dados, prefira guardar apenas um identificador/flag e revalidar no backend; evite guardar refresh token em localStorage quando possível.
Memória apenas: mais seguro, mas perde sessão ao recarregar. Pode ser aceitável em alguns cenários, mas não atende “persistência” sem um mecanismo adicional.
Neste capítulo, o foco é o fluxo e a sincronização. Os exemplos vão mostrar uma estratégia híbrida comum: access token em memória e refresh via cookie httpOnly. Se seu backend não suporta cookie httpOnly, você pode adaptar para refresh token no storage, mantendo os mesmos passos de UI e sincronização.
Passo a passo: fluxo de login com UI consistente
1) Formulário de login e estado de submissão
O formulário deve controlar o estado de envio para evitar múltiplos submits e para exibir feedback. Ao iniciar o login, a UI entra em “carregando” local (isLoggingIn) e, ao sucesso, o estado global muda para authenticated.
async function handleSubmit(e) { e.preventDefault(); setIsLoggingIn(true); setError(null); try { await auth.login({ email, password }); // após login, você pode navegar para a rota pretendida navigate(from ?? '/app'); } catch (err) { setError('Credenciais inválidas ou erro de rede'); } finally { setIsLoggingIn(false); }}Note que a navegação deve acontecer somente após o estado global estar atualizado, para evitar a UI renderizar uma área autenticada sem user carregado.
2) Chamada de API de login e armazenamento de credenciais
Um endpoint típico de login retorna um access token e dados do usuário, e define um cookie de refresh token (httpOnly) no response. O frontend guarda o access token em memória (por exemplo, no seu estado global) e guarda o user. Se o backend não retorna user, faça uma chamada subsequente para /me.
// authService.js (exemplo conceitual)export async function loginRequest(credentials) { const res = await fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', // importante para cookies body: JSON.stringify(credentials), }); if (!res.ok) throw new Error('Login failed'); return res.json(); // { accessToken, user } ou { accessToken }Se você receber apenas accessToken, faça:
export async function fetchMe(accessToken) { const res = await fetch('/api/me', { headers: { Authorization: `Bearer ${accessToken}` }, credentials: 'include', }); if (!res.ok) throw new Error('Me failed'); return res.json();}3) Atualização do estado global e “fonte da verdade”
Para sincronização de UI, defina uma única fonte da verdade: o estado global de auth. O login deve ser uma transação: ou você atualiza accessToken + user + status, ou você falha e mantém unauthenticated. Evite estados intermediários em que existe token mas não existe user por muito tempo, pois isso causa “meio logado” na UI.
// pseudo-implementação do login dentro do auth providerasync function login({ email, password }) { setState(s => ({ ...s, status: 'checking' })); const data = await loginRequest({ email, password }); const accessToken = data.accessToken; const user = data.user ?? await fetchMe(accessToken); setState({ status: 'authenticated', user, accessToken });}Repare no uso de status 'checking' durante a transação. Isso permite que componentes (menu, header, rotas) exibam skeleton/loading e não exibam conteúdo incorreto.
Persistência de sessão: restaurar ao iniciar a aplicação
1) O problema do “flash” de conteúdo
Quando a aplicação carrega, o React renderiza rapidamente. Se você marcar o usuário como unauthenticated por padrão e só depois tentar restaurar, pode haver um flash: a UI pública aparece por um instante e depois troca para a UI autenticada. O inverso também é perigoso: assumir autenticado só porque existe token em storage pode expor conteúdo indevido por alguns frames.
A solução é iniciar em status: 'checking' e renderizar uma UI neutra (ex.: splash/loading) até a verificação terminar.

2) Estratégia recomendada: refresh silencioso + /me
Com refresh token em cookie httpOnly, a restauração pode ser: (1) chamar /auth/refresh para obter novo access token, (2) com o access token, chamar /me, (3) atualizar estado para authenticated. Se falhar, virar unauthenticated.
// authService.jsexport async function refreshRequest() { const res = await fetch('/api/auth/refresh', { method: 'POST', credentials: 'include', }); if (!res.ok) throw new Error('Refresh failed'); return res.json(); // { accessToken }}// dentro do provider, ao montarasync function bootstrapAuth() { setState(s => ({ ...s, status: 'checking' })); try { const { accessToken } = await refreshRequest(); const user = await fetchMe(accessToken); setState({ status: 'authenticated', user, accessToken }); } catch { setState({ status: 'unauthenticated', user: null, accessToken: null }); }}Esse bootstrap deve rodar uma vez no início da aplicação. Enquanto status estiver 'checking', a UI deve evitar renderizar rotas privadas e também evitar redirecionar agressivamente para /login (senão você cria loops e flicker).
3) Persistência sem refresh token (fallback)
Se você precisa persistir via localStorage, uma abordagem mínima é guardar o access token e tentar /me ao iniciar. Se /me falhar (token expirado), você limpa o storage e marca unauthenticated. Ainda assim, comece em 'checking' para evitar flash.
const token = localStorage.getItem('accessToken');if (!token) { setState({ status: 'unauthenticated', user: null, accessToken: null });} else { try { const user = await fetchMe(token); setState({ status: 'authenticated', user, accessToken: token }); } catch { localStorage.removeItem('accessToken'); setState({ status: 'unauthenticated', user: null, accessToken: null }); }}Sincronização de UI: menus, permissões e dados dependentes do usuário
1) Renderização condicional baseada em status
Componentes como Header, Sidebar e páginas devem reagir ao status. Um padrão simples é:
checking: mostrar skeleton/placeholder e esconder ações sensíveis
authenticated: mostrar avatar, nome, links privados
unauthenticated: mostrar links públicos e botão “Entrar”
function Header() { const { status, user, logout } = useAuth(); if (status === 'checking') { return <div className="header">Carregando...</div>; } return ( <div className="header"> {status === 'authenticated' ? ( <> <span>Olá, {user.name}</span> <button onClick={logout}>Sair</button> </> ) : ( <a href="/login">Entrar</a> )} </div> );}O ponto central é: não use apenas “token existe” como critério de UI. Use status + user carregado.
2) Sincronizar dados “do usuário” após login
Após login, muitas telas dependem de dados do usuário (permissões, preferências, feature flags). Se você dispara várias requisições em paralelo, garanta que elas sejam canceladas/ignoradas no logout para evitar que respostas atrasadas “reloguem” a UI ou preencham caches indevidos.
Uma técnica simples é usar um “sessionId” em memória: a cada login/logout você incrementa um contador; requisições carregam o valor atual e só aplicam resultados se ainda for o mesmo.
let sessionVersion = 0;function bumpSession() { sessionVersion += 1; return sessionVersion; }async function loadUserStuff(currentVersion, accessToken) { const res = await fetch('/api/preferences', { headers: { Authorization: `Bearer ${accessToken}` }, }); const data = await res.json(); if (currentVersion !== sessionVersion) return; // ignora resposta atrasada // aplica no estado/caches}Fluxo de logout: local, remoto e limpeza completa
1) O que o logout precisa fazer
Um logout bem feito costuma incluir:
Limpar access token em memória (e em storage, se existir)
Limpar user e estados derivados (permissões, caches sensíveis)
Invalidar refresh token no servidor (se existir endpoint de logout)
Redirecionar para uma rota pública apropriada
Sincronizar logout em múltiplas abas
2) Logout com chamada ao backend
Se o backend mantém refresh token em cookie, o logout geralmente chama um endpoint que expira o cookie e invalida o refresh token no servidor.
export async function logoutRequest() { await fetch('/api/auth/logout', { method: 'POST', credentials: 'include', });}// dentro do providerasync function logout() { setState(s => ({ ...s, status: 'checking' })); try { await logoutRequest(); } finally { // limpeza local sempre acontece setState({ status: 'unauthenticated', user: null, accessToken: null }); // se você usa localStorage: localStorage.removeItem('accessToken'); }}Mesmo que a requisição falhe (offline), você ainda deve limpar o estado local para não manter a UI como autenticada.
3) Limpeza de caches e dados sensíveis
Se você usa bibliotecas de cache (ou mesmo caches manuais), limpe dados que não devem sobreviver ao logout. Exemplos: perfil, listas privadas, permissões. Se você usa React Query, por exemplo, você pode remover queries relacionadas ao usuário. Em cache manual, redefina para estado inicial.
// exemplo genérico de limpeza após logoutfunction clearPrivateCaches() { // reset de stores, caches em memória, etc.}Sincronização entre abas: login/logout refletindo em tempo real
Um problema comum: o usuário faz logout em uma aba e a outra continua “logada” até fazer uma requisição e receber 401. Para UX e segurança, é melhor sincronizar imediatamente.
1) Usando o evento storage (localStorage)
Mesmo que você não guarde tokens no localStorage, você pode usar o localStorage como “canal” de broadcast. Ao fazer login/logout, escreva um valor e as outras abas recebem o evento.
// ao logarlocalStorage.setItem('auth:event', JSON.stringify({ type: 'login', at: Date.now() }));// ao deslogarlocalStorage.setItem('auth:event', JSON.stringify({ type: 'logout', at: Date.now() }));// listener global (ex.: no provider)useEffect(() => { function onStorage(e) { if (e.key !== 'auth:event' || !e.newValue) return; const msg = JSON.parse(e.newValue); if (msg.type === 'logout') { // limpa estado local e navega se necessário setState({ status: 'unauthenticated', user: null, accessToken: null }); } if (msg.type === 'login') { // opcional: revalidar sessão (bootstrap) bootstrapAuth(); } } window.addEventListener('storage', onStorage); return () => window.removeEventListener('storage', onStorage);}, []);Isso resolve sincronização entre abas sem expor tokens. O evento storage só dispara em outras abas, não na mesma.
2) Usando BroadcastChannel (quando disponível)
BroadcastChannel é mais direto e não depende de storage. Você envia mensagens e todas as abas recebem.

const channel = new BroadcastChannel('auth');channel.postMessage({ type: 'logout' });channel.onmessage = (event) => { if (event.data?.type === 'logout') { setState({ status: 'unauthenticated', user: null, accessToken: null }); }};Se você precisa suportar navegadores antigos, mantenha o fallback via storage.
Expiração de token e sincronização automática (401, refresh e retry)
1) Interceptar 401 e tentar refresh
Em SPAs com JWT, o access token expira. A UI não deve “quebrar” do nada; o ideal é tentar refresh silencioso e repetir a requisição original. Se refresh falhar, faça logout local e redirecione para login.
Se você usa fetch puro, pode criar um wrapper:
let refreshPromise = null;async function authFetch(input, init = {}) { const { accessToken } = authState.get(); // forma de ler estado atual const headers = new Headers(init.headers || {}); if (accessToken) headers.set('Authorization', `Bearer ${accessToken}`); const res = await fetch(input, { ...init, headers, credentials: 'include' }); if (res.status !== 401) return res; // tenta refresh (com deduplicação) if (!refreshPromise) { refreshPromise = refreshRequest() .then(({ accessToken: newToken }) => { authState.setToken(newToken); return newToken; }) .finally(() => { refreshPromise = null; }); } try { const newToken = await refreshPromise; const retryHeaders = new Headers(init.headers || {}); retryHeaders.set('Authorization', `Bearer ${newToken}`); return await fetch(input, { ...init, headers: retryHeaders, credentials: 'include' }); } catch { authState.clear(); // status unauthenticated, user null, token null throw new Error('Session expired'); }}O detalhe importante é a deduplicação: se 5 requisições recebem 401 ao mesmo tempo, você não quer 5 refresh em paralelo. Um refreshPromise compartilhado resolve isso.
2) Atualizar UI quando a sessão expira
Quando o refresh falha, a UI deve transicionar para unauthenticated imediatamente. Isso evita que componentes continuem exibindo dados privados. Se você estiver em uma rota protegida, o guard/roteamento deve redirecionar para login, mas sem “piscar”: primeiro marque unauthenticated, depois navegue.
Redirecionamento pós-login: voltar para a página pretendida
Um fluxo de login consistente preserva a intenção do usuário. Se ele tentou acessar uma rota protegida, após login ele deve voltar para ela. Isso normalmente é feito passando um “from” no estado de navegação ou querystring. O ponto de sincronização aqui é: só redirecione após status virar authenticated e user estar disponível, para evitar que a página destino renderize sem dados.
// exemplo conceitual: ao bloquear acesso, salvar destino// navigate('/login', { state: { from: location.pathname } })// no login: const from = location.state?.from;Erros comuns e como evitar
1) “Token existe, então está logado”
Evite basear a UI apenas na presença de token em storage. Token pode estar expirado. Prefira status 'checking' e validação via /me ou refresh.
2) Flash de conteúdo privado
Se você renderiza rotas privadas antes de terminar o bootstrap, pode haver flash. Solução: enquanto status for 'checking', renderize um placeholder e não renderize conteúdo privado.
3) Requisições atrasadas após logout
Respostas de requisições iniciadas antes do logout podem chegar depois e sobrescrever estado. Use cancelamento (AbortController) ou ignore respostas com sessionVersion.
4) Múltiplas abas fora de sincronia
Sem storage/BroadcastChannel, uma aba pode ficar “logada” até receber 401. Em apps com dados sensíveis, sincronize eventos de login/logout.
5) Refresh em loop
Se o refresh falha e você tenta refresh em toda requisição, pode criar loop. Ao falhar, marque unauthenticated e pare de tentar até novo login.