Estruturando aplicações React de forma escalável

Estruturando aplicações React de forma escalável

O React se tornou uma das bibliotecas JavaScript mais populares para construção de interfaces de usuário incríveis. Sua flexibilidade e eficiência permitiram o desenvolvimento de aplicações web modernas e dinâmicas. No entanto, à medida que os projetos React crescem em escala e complexidade, surge a necessidade de adotar uma estrutura sólida que promova a manutenção, a reutilização de código e, é claro, a escalabilidade. Neste artigo, abordaremos algumas práticas para estruturar aplicações React de forma escalável, destacando a importância da organização do código.

Mas já adianto que o que vou explicar aqui não é a regra definitiva, com certeza existem muitas formas de se estruturar aplicações. Porém, quero apresentar a minha visão sobre esse assunto, já que não vejo sendo muito falado. Para isso, vou abordar 3 pontos principais de apps React: componentes, hooks e contexto.

Definição prévia de termos técnicos

Antes de explorarmos as práticas para estruturar aplicações React de forma escalável, é útil estabelecer uma definição prévia de alguns termos técnicos frequentemente utilizados. Isso nos ajudará a garantir que todos os leitores, independentemente do seu nível de conhecimento, estejam familiarizados com os conceitos abordados ao longo do texto. Vamos lá:

Componentes: No contexto do React, um componente é uma unidade autônoma e reutilizável que encapsula a lógica e a renderização da interface do usuário. Os componentes são construídos a partir de elementos React e podem ser compostos uns com os outros para criar hierarquias complexas. Essa abordagem modular facilita a manutenção, teste e reutilização de código.

Hooks: Os hooks são funções especiais fornecidas pelo React que permitem o uso de recursos do React, como o estado e o ciclo de vida, em componentes funcionais. Eles permitem que os componentes funcionais tenham estado interno e acessem recursos do React sem a necessidade de converter os componentes para classes. Com os hooks, é possível adicionar comportamentos e funcionalidades aos componentes de forma mais simples e legível.

Contexto: O contexto é um recurso do React que permite compartilhar dados entre componentes sem a necessidade de passá-los explicitamente por meio das propriedades. É especialmente útil quando temos vários componentes aninhados que precisam acessar o mesmo dado. O contexto cria um "canal" de comunicação global, permitindo que os componentes consumam e atualizem o dado fornecido pelo provedor de contexto.

Agora que estabelecemos as definições dos termos técnicos fundamentais, estamos prontos para avançar.

A Importância da Estruturação Escalável

A organização adequada do código em uma aplicação React desempenha um papel fundamental no sucesso do projeto. Uma boa estrutura não só torna a aplicação mais fácil de entender e manter, mas também facilita a sua expansão ao longo do tempo. Além disso, uma boa estruturação promove a reutilização de código, reduzindo a duplicação de esforços e agilizando o processo de desenvolvimento. Investir na estruturação escalável desde o início é essencial para evitar problemas futuros e garantir um desenvolvimento suave, proporcionando uma base sólida para o crescimento e aprimoramento contínuos da aplicação.

Para entender melhor a importância do que iremos explicar mais adiante, vamos considerar um exemplo de aplicação mal estruturada. Nesse exemplo fictício, temos um mini comércio eletrônico que está crescendo em complexidade à medida que mais recursos são adicionados. No entanto, a estrutura do código não acompanha esse crescimento, resultando em um código desorganizado e difícil de manter.

No código abaixo, podemos ver uma implementação inicial da aplicação:

import React, { useState, useEffect } from "react";

export const App = () => {
  const [products, setProducts] = useState([]);
  const [cart, setCart] = useState([]);
  const [searchTerm, setSearchTerm] = useState("");

  useEffect(() => {
    fetchProducts();
  }, []);

  const fetchProducts = async () => {
    const response = await fetch("https://dummyjson.com/products/?limit=10");
    const { products } = await response.json();
    setProducts(products);
  };

  const addToCart = (product) => {
    setCart([...cart, product]);
  };

  const removeFromCart = (item) => {
    const updatedCart = cart.filter((cartItem) => cartItem.id !== item.id);
    setCart(updatedCart);
  };

  const handleSearch = (event) => {
    setSearchTerm(event.target.value);
  };

  const filteredProducts = products.filter((product) =>
    product.title.toLowerCase().includes(searchTerm.toLowerCase())
  );

  return (
    <div>
      <h1>Mini Comércio Eletrônico</h1>
      <div>
        <h2>Produtos</h2>
        <input
          type="text"
          placeholder="Pesquisar produtos"
          value={searchTerm}
          onChange={handleSearch}
        />
        <ul>
          {filteredProducts.map((product) => (
            <li key={product.id}>
              {product.title} - R$ {product.price.toFixed(2)}
              <button onClick={() => addToCart(product)}>
                Adicionar ao Carrinho
              </button>
            </li>
          ))}
        </ul>
      </div>
      <div>
        <h2>Carrinho</h2>
        <ul>
          {cart.map((item) => (
            <li key={item.id}>
              {item.title} - R$ {item.price.toFixed(2)}
              <button onClick={() => removeFromCart(item)}>
                Remover do Carrinho
              </button>
            </li>
          ))}
        </ul>
      </div>
    </div>
  );
};


Nesse exemplo, todas as responsabilidades da aplicação estão no componente App. Isso inclui o estado dos produtos, carrinho e termo de pesquisa, a adição e remoção de itens ao carrinho, bem como lidar com a pesquisa de produtos. Além disso, renderiza as listas de produtos e de carrinho. Conforme a aplicação cresce, essa estrutura monolítica se torna insustentável, dificultando a compreensão, a manutenção e a testabilidade do código. Então como podemos melhorar isso?

1. Componentes como Unidades Independentes

Uma prática fundamental para estruturar aplicações React é a criação de componentes como unidades independentes. Isso significa que cada componente deve ser responsável por uma única funcionalidade ou parte da interface de usuário. Ao dividir a interface em componentes menores e mais específicos, é possível obter uma estrutura modular e reutilizável.

No exemplo anterior, podemos reestruturar o código em componentes independentes, como ProductList e Cart, para melhorar a organização e a reutilização de código.

import React, { useState, useEffect } from "react";

const ProductList = ({ products, addToCart }) => {
  const [searchTerm, setSearchTerm] = useState("");

  const handleSearch = (event) => {
    setSearchTerm(event.target.value);
  };

  const filteredProducts = products.filter((product) =>
    product.title.toLowerCase().includes(searchTerm.toLowerCase())
  );

  return (
    <div>
      <h2>Produtos</h2>
      <input
        type="text"
        placeholder="Pesquisar produtos"
        value={searchTerm}
        onChange={handleSearch}
      />
      <ul>
        {filteredProducts.map((product) => (
          <li key={product.id}>
            {product.title} - R$ {product.price.toFixed(2)}
            <button onClick={() => addToCart(product)}>
              Adicionar ao Carrinho
            </button>
          </li>
        ))}
      </ul>
    </div>
  );
};

const Cart = ({ cart, removeFromCart }) => {
  return (
    <div>
      <h2>Carrinho</h2>
      <ul>
        {cart.map((item) => (
          <li key={item.id}>
            {item.title} - R$ {item.price.toFixed(2)}
            <button onClick={() => removeFromCart(item)}>
              Remover do Carrinho
            </button>
          </li>
        ))}
      </ul>
    </div>
  );
};

export const App = () => {
  const [products, setProducts] = useState([]);
  const [cart, setCart] = useState([]);

  useEffect(() => {
    fetchProducts();
  }, []);

  const fetchProducts = async () => {
    const response = await fetch("<https://dummyjson.com/products/?limit=10>");
    const { products } = await response.json();
    setProducts(products);
  };

  const addToCart = (product) => {
    setCart([...cart, product]);
  };

  const removeFromCart = (item) => {
    const updatedCart = cart.filter((cartItem) => cartItem.id !== item.id);
    setCart(updatedCart);
  };

  return (
    <div>
      <h1>Mini Comércio Eletrônico</h1>
      <ProductList products={products} addToCart={addToCart} />
      <Cart cart={cart} removeFromCart={removeFromCart} />
    </div>
  );
};


No exemplo atualizado, o componente ProductList gerencia a exibição dos produtos, incluindo a funcionalidade de pesquisa e a adição de itens ao carrinho. Já o componente Cart é responsável pela exibição dos itens no carrinho e pela remoção deles.

Essa abordagem de componentes independentes facilita a manutenção, teste e reutilização do código, pois agora a listagem de produtos e o carrinho podem ser incluídos em diversas partes da aplicação, sem estarem necessariamente juntos. Além de permitir o uso dos hooks do React para adicionar lógica e efeitos específicos em cada componente.

2. Hooks Personalizados para Lógica de Negócio

Os hooks personalizados são uma poderosa ferramenta para a organização e reutilização da lógica de negócio em aplicações React. Eles permitem que a lógica complexa seja tratada separadamente e promovem a reutilização de código, permitindo que a lógica de negócio seja compartilhada entre diferentes componentes de forma simples.

No exemplo anterior, poderíamos criar um hook personalizado para lidar com a lógica de filtrar os produtos:

const useProductFilter = (products) => {
  const [searchTerm, setSearchTerm] = useState('');

  const handleSearch = (event) => {
    setSearchTerm(event.target.value);
  };

  const filteredProducts = products.filter((product) =>
    product.title.toLowerCase().includes(searchTerm.toLowerCase())
  );

  return [searchTerm, handleSearch, filteredProducts];
};


Com esse hook personalizado, podemos simplificar o componente ProductList:

const ProductList = ({ products, addToCart }) => {
  const [searchTerm, handleSearch, filteredProducts] =
    useProductFilter(products);

  return (
    <div>
      <h2>Produtos</h2>
      <input
        type="text"
        placeholder="Pesquisar produtos"
        value={searchTerm}
        onChange={handleSearch}
      />
      <ul>
        {filteredProducts.map((product) => (
          <li key={product.id}>
            {product.title} - R$ {product.price.toFixed(2)}
            <button onClick={() => addToCart(product)}>
              Adicionar ao Carrinho
            </button>
          </li>
        ))}
      </ul>
    </div>
  );
};


Essa abordagem de usar hooks personalizados ajuda a manter os componentes mais limpos e focados em sua responsabilidade principal, enquanto a lógica complexa é tratada separadamente. Os hooks também promovem a reutilização de código, permitindo que a lógica de negócio seja compartilhada entre diferentes componentes de forma simples.

3. Gerenciamento de Estado Global com Contexto

O gerenciamento de estado é uma consideração importante ao lidar com aplicações React de médio a grande porte. Uma das maneiras de lidar com o estado global é utilizando o contexto do React.

Continuando o nosso exemplo, podemos utilizar o contexto para compartilhar o estado dos produtos e carrinho entre os componentes, eliminando a necessidade de passar props através de vários níveis da árvore de componentes.

import { createContext, useEffect } from "react";

export const ProductsContext = createContext();

export const ProductsProvider = ({ children }) => {
  const [products, setProducts] = useState([]);
  const [cart, setCart] = useState([]);

  useEffect(() => {
    fetchProducts();
  }, []);

  const fetchProducts = async () => {
    const response = await fetch("<https://dummyjson.com/products/?limit=10>");
    const { products } = await response.json();
    setProducts(products);
  };

  const addToCart = (product) => {
    setCart([...cart, product]);
  };

  const removeFromCart = (item) => {
    const updatedCart = cart.filter((cartItem) => cartItem.id !== item.id);
    setCart(updatedCart);
  };

  return (
    <ProductsContext.Provider
      value={{
        products,
        cart,
        addToCart,
        removeFromCart,
      }}
    >
      {children}
    </ProductsContext.Provider>
  );
};


Aqui criamos um contexto ProductsContext e o provedor ProductsProvider, que envolve os componentes da aplicação que precisam acessar o contexto. Nele estão os estados dos produtos e carrinhos, e as funções para manipular os estados. Dessa forma, qualquer componente descendente desses componentes pode acessar o estado e as funções fornecidas pelo contexto.

const ProductList = () => {
  const { products, addToCart } = useContext(ProductsContext);
  const [searchTerm, handleSearch, filteredProducts] =
    useProductFilter(products);

  // resto do código...
};

const Cart = () => {
  const { cart, removeFromCart } = useContext(ProductsContext);
  // resto do código...
};

export const App = () => {
  return (
    <ProductsProvider>
      <h1>Mini Comércio Eletrônico</h1>
      <ProductList />
      <Cart />
    </ProductsProvider>
  );
};


Nesses componentes, utilizamos a função useContext para acessar as informações e as funções do ProductsContext. Dessa forma, não precisamos mais passar as props manualmente entre os componentes. Inclusive, se seguíssemos com esse exemplo e fosse necessário aumentar funcionalidades, poderia se dividir em dois contextos, um para os produtos e outro para o carrinho, que separaria ainda mais as responsabilidades.

O uso do contexto simplifica o gerenciamento de estado global e evita a propagação desnecessária de props em uma aplicação de grande escala. Além disso, acaba facilitando bastante a testabilidade do código, pois é possível fazer “mock” de valores e funções apenas passando um novo provedor com os valores e assim testar os componentes que desejar. No entanto, é importante ter cuidado ao utilizar o contexto em excesso, pois isso pode tornar o código mais complexo e difícil de entender. Utilize o contexto de forma seletiva e apenas para os dados e funcionalidades realmente compartilhados por vários componentes.

Conclusão

Estruturar aplicações React de forma escalável é essencial para garantir uma base sólida para o crescimento e manutenção do código. Ao dividir a interface em componentes independentes, utilizar hooks personalizados para lógica de negócio e adotar o contexto para o gerenciamento de estado global, podemos obter uma estrutura mais organizada, modular, reutilizável e mais facilmente testável.

Mas é importante ressaltar novamente que não existe uma regra definitiva para estruturar aplicações React, e as práticas apresentadas são apenas sugestões. Cada projeto pode ter suas particularidades e exigir abordagens específicas. Por exemplo, em muitos projetos o uso de contexto pode não ser a melhor alternativa, devido à complexidade dos estados. Então, o mais importante é buscar formas que contribuam para a legibilidade, manutenção e escalabilidade das aplicações, permitindo um desenvolvimento mais eficiente e livre de problemas futuros.

De toda forma, parabéns por ter chegado até aqui! Espero ter contribuído de forma eficaz para seus próximos projetos.

💡
As opiniões e comentários expressos neste artigo são de propriedade exclusiva de seu autor e não representam necessariamente o ponto de vista da Revelo.

A Revelo Content Network acolhe todas as raças, etnias, nacionalidades, credos, gêneros, orientações, pontos de vista e ideologias, desde que promovam diversidade, equidade, inclusão e crescimento na carreira dos profissionais de tecnologia.