useReducer em Profundidade: State Machines e Fluxo Previsível: Do Básico ao Avançado Já leu

Entendendo useReducer: Além do useState O é um hook do React que oferece uma abordagem mais estruturada e previsível para gerenciar estado complexo em componentes funcionais. Enquanto é perfeito para estado simples (um valor booleano, uma string, um número), o brilha quando você tem múltiplas transições de estado interdependentes ou lógica condicional complexa. A diferença fundamental está na previsibilidade. Com , toda mudança de estado passa por uma função pura chamada reducer, que recebe o estado atual e uma ação, retornando o novo estado. Isso torna o fluxo de dados explícito e testável. Não há efeitos colaterais escondidos; tudo é determinístico. State Machines: Modelando Comportamento Previsível O que é uma State Machine? Uma máquina de estados é um modelo abstrato que define um conjunto finito de estados, transições entre esses estados e ações que disparam essas transições. No contexto do React com , isso significa que seu componente pode estar em estados bem definidos (como , , , ) e

Entendendo useReducer: Além do useState

O useReducer é um hook do React que oferece uma abordagem mais estruturada e previsível para gerenciar estado complexo em componentes funcionais. Enquanto useState é perfeito para estado simples (um valor booleano, uma string, um número), o useReducer brilha quando você tem múltiplas transições de estado interdependentes ou lógica condicional complexa.

A diferença fundamental está na previsibilidade. Com useReducer, toda mudança de estado passa por uma função pura chamada reducer, que recebe o estado atual e uma ação, retornando o novo estado. Isso torna o fluxo de dados explícito e testável. Não há efeitos colaterais escondidos; tudo é determinístico.

State Machines: Modelando Comportamento Previsível

O que é uma State Machine?

Uma máquina de estados é um modelo abstrato que define um conjunto finito de estados, transições entre esses estados e ações que disparam essas transições. No contexto do React com useReducer, isso significa que seu componente pode estar em estados bem definidos (como idle, loading, success, error) e apenas transições válidas são permitidas.

Considere um formulário de login: ele pode estar em idle (esperando interação), loading (enviando dados), success (login realizado) ou error (falha). Não faz sentido transicionar de success diretamente para loading sem voltar a idle. Uma máquina de estados garante que essas transições inválidas não aconteçam.

Implementando uma State Machine com useReducer

Vamos construir um exemplo prático: um carregador de arquivo com estados bem definidos.

import React, { useReducer } from 'react';

// Tipos de ações
const ACTIONS = {
  START_UPLOAD: 'START_UPLOAD',
  UPLOAD_PROGRESS: 'UPLOAD_PROGRESS',
  UPLOAD_SUCCESS: 'UPLOAD_SUCCESS',
  UPLOAD_ERROR: 'UPLOAD_ERROR',
  RESET: 'RESET',
};

// Estado inicial
const initialState = {
  status: 'idle', // 'idle' | 'uploading' | 'success' | 'error'
  progress: 0,
  error: null,
  fileName: '',
};

// Função reducer pura
function fileUploadReducer(state, action) {
  switch (action.type) {
    case ACTIONS.START_UPLOAD:
      return {
        ...state,
        status: 'uploading',
        fileName: action.payload.fileName,
        progress: 0,
        error: null,
      };

    case ACTIONS.UPLOAD_PROGRESS:
      return {
        ...state,
        progress: action.payload.progress,
      };

    case ACTIONS.UPLOAD_SUCCESS:
      return {
        ...state,
        status: 'success',
        progress: 100,
      };

    case ACTIONS.UPLOAD_ERROR:
      return {
        ...state,
        status: 'error',
        error: action.payload.message,
      };

    case ACTIONS.RESET:
      return initialState;

    default:
      return state;
  }
}

// Componente que utiliza a máquina de estados
function FileUploadComponent() {
  const [state, dispatch] = useReducer(fileUploadReducer, initialState);

  const handleFileSelect = async (event) => {
    const file = event.target.files[0];
    if (!file) return;

    dispatch({
      type: ACTIONS.START_UPLOAD,
      payload: { fileName: file.name },
    });

    try {
      // Simulando upload com progresso
      for (let i = 0; i <= 100; i += 20) {
        await new Promise(resolve => setTimeout(resolve, 300));
        dispatch({
          type: ACTIONS.UPLOAD_PROGRESS,
          payload: { progress: i },
        });
      }

      dispatch({ type: ACTIONS.UPLOAD_SUCCESS });
    } catch (error) {
      dispatch({
        type: ACTIONS.UPLOAD_ERROR,
        payload: { message: 'Falha no upload' },
      });
    }
  };

  return (
    <div>
      <input
        type="file"
        onChange={handleFileSelect}
        disabled={state.status === 'uploading'}
      />

      {state.status === 'idle' && <p>Selecione um arquivo</p>}

      {state.status === 'uploading' && (
        <div>
          <p>Enviando: {state.fileName}</p>
          <progress value={state.progress} max="100" />
          <p>{state.progress}%</p>
        </div>
      )}

      {state.status === 'success' && (
        <p>✓ Arquivo enviado com sucesso!</p>
      )}

      {state.status === 'error' && (
        <p>✗ Erro: {state.error}</p>
      )}

      {state.status !== 'idle' && (
        <button onClick={() => dispatch({ type: ACTIONS.RESET })}>
          Limpar
        </button>
      )}
    </div>
  );
}

export default FileUploadComponent;

Observe como cada ação é explícita e o estado é imutável. Não há efeitos colaterais no reducer; toda lógica assíncrona fica no componente. Isso torna debugging trivial: você sabe exatamente qual ação levou a qual estado.

Fluxo Previsível: Estruturando Transições Válidas

Por que a Previsibilidade Importa?

Quando você tem múltiplos estados e suas transições são implícitas (como em código imperativo puro), é fácil cair em situações inválidas. Por exemplo, mostrar um botão de "enviar" enquanto está carregando, ou tentar fazer outra ação enquanto um carregamento está em progresso. Máquinas de estados eliminam essas falhas estruturais.

Implementando Validação de Transições

Vamos criar um exemplo com formulário mais complexo que respeita transições válidas:

import React, { useReducer } from 'react';

const ACTIONS = {
  FILL_FORM: 'FILL_FORM',
  SUBMIT_START: 'SUBMIT_START',
  SUBMIT_SUCCESS: 'SUBMIT_SUCCESS',
  SUBMIT_ERROR: 'SUBMIT_ERROR',
  RETRY: 'RETRY',
  RESET_FORM: 'RESET_FORM',
};

const initialState = {
  status: 'editing', // 'editing' | 'submitting' | 'success' | 'error'
  formData: {
    email: '',
    password: '',
  },
  validationErrors: {},
  submitError: null,
  attemptCount: 0,
};

function formReducer(state, action) {
  // Validação de transições: apenas ações válidas para cada estado
  const validTransitions = {
    editing: [ACTIONS.FILL_FORM, ACTIONS.SUBMIT_START, ACTIONS.RESET_FORM],
    submitting: [ACTIONS.SUBMIT_SUCCESS, ACTIONS.SUBMIT_ERROR],
    success: [ACTIONS.RESET_FORM],
    error: [ACTIONS.RETRY, ACTIONS.RESET_FORM, ACTIONS.FILL_FORM],
  };

  if (!validTransitions[state.status].includes(action.type)) {
    console.warn(
      `Transição inválida: ${state.status} -> ${action.type}`
    );
    return state; // Ignora ações inválidas
  }

  switch (action.type) {
    case ACTIONS.FILL_FORM:
      return {
        ...state,
        formData: {
          ...state.formData,
          ...action.payload,
        },
        validationErrors: {},
      };

    case ACTIONS.SUBMIT_START:
      return {
        ...state,
        status: 'submitting',
        submitError: null,
      };

    case ACTIONS.SUBMIT_SUCCESS:
      return {
        ...state,
        status: 'success',
        attemptCount: state.attemptCount + 1,
      };

    case ACTIONS.SUBMIT_ERROR:
      return {
        ...state,
        status: 'error',
        submitError: action.payload.error,
        validationErrors: action.payload.validationErrors || {},
      };

    case ACTIONS.RETRY:
      return {
        ...state,
        status: 'submitting',
        submitError: null,
      };

    case ACTIONS.RESET_FORM:
      return initialState;

    default:
      return state;
  }
}

function LoginForm() {
  const [state, dispatch] = useReducer(formReducer, initialState);

  const handleInputChange = (e) => {
    const { name, value } = e.target;
    dispatch({
      type: ACTIONS.FILL_FORM,
      payload: { [name]: value },
    });
  };

  const handleSubmit = async (e) => {
    e.preventDefault();

    dispatch({ type: ACTIONS.SUBMIT_START });

    try {
      // Simulando validação e envio
      await new Promise(resolve => setTimeout(resolve, 1500));

      // Validação simples
      if (!state.formData.email.includes('@')) {
        throw {
          message: 'Email inválido',
          validationErrors: { email: 'Email deve conter @' },
        };
      }

      if (state.formData.password.length < 6) {
        throw {
          message: 'Senha fraca',
          validationErrors: { password: 'Mínimo 6 caracteres' },
        };
      }

      dispatch({ type: ACTIONS.SUBMIT_SUCCESS });
    } catch (error) {
      dispatch({
        type: ACTIONS.SUBMIT_ERROR,
        payload: {
          error: error.message,
          validationErrors: error.validationErrors,
        },
      });
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label>Email:</label>
        <input
          type="email"
          name="email"
          value={state.formData.email}
          onChange={handleInputChange}
          disabled={state.status === 'submitting'}
        />
        {state.validationErrors.email && (
          <span style={{ color: 'red' }}>
            {state.validationErrors.email}
          </span>
        )}
      </div>

      <div>
        <label>Senha:</label>
        <input
          type="password"
          name="password"
          value={state.formData.password}
          onChange={handleInputChange}
          disabled={state.status === 'submitting'}
        />
        {state.validationErrors.password && (
          <span style={{ color: 'red' }}>
            {state.validationErrors.password}
          </span>
        )}
      </div>

      {state.status === 'editing' && (
        <button type="submit">Entrar</button>
      )}

      {state.status === 'submitting' && (
        <button disabled>Carregando...</button>
      )}

      {state.status === 'success' && (
        <div>
          <p>✓ Login realizado! Tentativas: {state.attemptCount}</p>
          <button
            type="button"
            onClick={() => dispatch({ type: ACTIONS.RESET_FORM })}
          >
            Fazer novo login
          </button>
        </div>
      )}

      {state.status === 'error' && (
        <div>
          <p style={{ color: 'red' }}>✗ {state.submitError}</p>
          <button
            type="button"
            onClick={() => dispatch({ type: ACTIONS.RETRY })}
          >
            Tentar novamente
          </button>
          <button
            type="button"
            onClick={() => dispatch({ type: ACTIONS.RESET_FORM })}
          >
            Cancelar
          </button>
        </div>
      )}
    </form>
  );
}

export default LoginForm;

Neste exemplo, a máquina de estados é explícita sobre transições válidas. Se alguém tentar executar uma ação impossível (como FILL_FORM enquanto submitting), a transição é ignorada. Isso previne bugs silenciosos e torna o comportamento completamente previsível.

Padrões Avançados e Otimizações

Reducers Compostos

Para aplicações maiores, você pode combinar múltiplos reducers usando padrões de composição. Ao invés de um único reducer monolítico, separe por domínio:

import React, { useReducer, useCallback } from 'react';

// Reducer para UI
function uiReducer(state, action) {
  switch (action.type) {
    case 'TOGGLE_MODAL':
      return { ...state, showModal: !state.showModal };
    case 'SET_LOADING':
      return { ...state, isLoading: action.payload };
    default:
      return state;
  }
}

// Reducer para dados
function dataReducer(state, action) {
  switch (action.type) {
    case 'SET_DATA':
      return { ...state, items: action.payload };
    case 'ADD_ITEM':
      return { ...state, items: [...state.items, action.payload] };
    default:
      return state;
  }
}

// Reducer que combina ambos
function appReducer(state, action) {
  return {
    ui: uiReducer(state.ui, action),
    data: dataReducer(state.data, action),
  };
}

const initialState = {
  ui: { showModal: false, isLoading: false },
  data: { items: [] },
};

function App() {
  const [state, dispatch] = useReducer(appReducer, initialState);

  const loadData = useCallback(async () => {
    dispatch({ type: 'SET_LOADING', payload: true });

    try {
      const response = await fetch('/api/items');
      const data = await response.json();
      dispatch({ type: 'SET_DATA', payload: data });
    } finally {
      dispatch({ type: 'SET_LOADING', payload: false });
    }
  }, []);

  return (
    <div>
      <button onClick={() => dispatch({ type: 'TOGGLE_MODAL' })}>
        Abrir Modal
      </button>
      <button onClick={loadData} disabled={state.ui.isLoading}>
        {state.ui.isLoading ? 'Carregando...' : 'Carregar Dados'}
      </button>
      {state.ui.showModal && <div>Modal aberto com {state.data.items.length} itens</div>}
    </div>
  );
}

export default App;

Integração com useContext

Para compartilhar estado reduzido entre múltiplos componentes sem prop drilling, combine useReducer com useContext:

import React, { useReducer, useContext, createContext } from 'react';

const StateContext = createContext();
const DispatchContext = createContext();

const ACTIONS = {
  INCREMENT: 'INCREMENT',
  DECREMENT: 'DECREMENT',
  RESET: 'RESET',
};

function counterReducer(state, action) {
  switch (action.type) {
    case ACTIONS.INCREMENT:
      return { count: state.count + (action.payload || 1) };
    case ACTIONS.DECREMENT:
      return { count: state.count - (action.payload || 1) };
    case ACTIONS.RESET:
      return { count: 0 };
    default:
      return state;
  }
}

export function CounterProvider({ children }) {
  const [state, dispatch] = useReducer(counterReducer, { count: 0 });

  return (
    <StateContext.Provider value={state}>
      <DispatchContext.Provider value={dispatch}>
        {children}
      </DispatchContext.Provider>
    </StateContext.Provider>
  );
}

export function useCounterState() {
  const context = useContext(StateContext);
  if (!context) {
    throw new Error('useCounterState deve estar dentro de CounterProvider');
  }
  return context;
}

export function useCounterDispatch() {
  const context = useContext(DispatchContext);
  if (!context) {
    throw new Error('useCounterDispatch deve estar dentro de CounterProvider');
  }
  return context;
}

// Componentes que usam o context
function Counter() {
  const { count } = useCounterState();
  const dispatch = useCounterDispatch();

  return (
    <div>
      <p>Contagem: {count}</p>
      <button onClick={() => dispatch({ type: ACTIONS.INCREMENT })}>+</button>
      <button onClick={() => dispatch({ type: ACTIONS.DECREMENT })}>-</button>
      <button onClick={() => dispatch({ type: ACTIONS.RESET })}>Reset</button>
    </div>
  );
}

function App() {
  return (
    <CounterProvider>
      <Counter />
    </CounterProvider>
  );
}

export default App;

Conclusão

Aprendemos que o useReducer não é apenas uma alternativa ao useState; é um padrão para construir sistemas previsíveis e mantíveis. Três aprendizados principais consolidam esse conhecimento:

  1. Máquinas de Estados trazem clareza: Ao definir explicitamente quais estados existem e como transições ocorrem, você elimina uma classe inteira de bugs implícitos. O código fica mais fácil de entender, testar e debugar.

  2. Reducers puros são testáveis: Uma função pura que recebe estado e ação, retornando novo estado, é trivial de testar sem mocks ou efeitos colaterais. Isso melhora significativamente a qualidade do código.

  3. Escalabilidade estruturada: Padrões como reducers compostos e integração com Context permitem que sistemas complexos cresçam de forma organizada, mantendo previsibilidade mesmo com múltiplos domínios de estado.

Referências


Artigos relacionados