Mejores Prácticas para Gestionar y Optimizar Componentes en React

Published on
Mejores Prácticas para Gestionar y Optimizar Componentes en React

Mejores Prácticas para Gestionar y Optimizar Componentes en React

Valores de Codificación rígida

Comencemos hablando de valores constantes o valores codificados. Normalmente tenemos valores predeterminados que están codificados (no cambian) y se necesitan en diferentes archivos o componentes.

En nuestro ejemplo, podríamos tener un valor que indica cuál es el número máximo de tareas pendientes antes de que se le pida al usuario que inicie sesión para agregar más. ¿Qué pasa si cambiamos de opinión en el futuro y permitimos a nuestros usuarios más tareas pendientes?

También podríamos tener una lista de palabras que sabemos que pueden ser información sensible y avisar al usuario que no podemos procesar la solicitud.

Lo que solemos hacer en estos escenarios es crear una variable separada fuera de nuestro componente o archivo para almacenar estos valores, lo que nos ayuda a cambiar un valor globalmente sin necesidad de buscarlo en varios archivos o líneas de código. Un estándar común es usar mayúsculas en la palabra en Snake CaseExternal Link.

Por último, si tenemos datos constantes iniciales en un arreglo u objeto dentro del componente, esos datos se recrearán en cada renderizado. Para un arreglo u objeto pequeño, puede que no sea un problema, pero es algo a tener en cuenta. Una mejor solución sería mover los valores constantes fuera del componente, lo que también ayuda a mantener el componente limpio.

export const MAX_FREE_TIER = 5;
export const SENSITIVE_WORDS = ['password123', 'credit card', 'admin'];
@/app/lib/constants.ts
'use client'; // used only for nextjs

import React, { useState } from 'react';
import { MAX_FREE_TIER, SENSITIVE_WORDS } from '@/app/lib/constants';

type Todo = {
  id: number;
  content: string;
  completed: boolean;
};

// const MAX_DATA = 10;

const initialData = [
  {
    id: 1,
    content: 'Check car oil',
    completed: false,
  },
  {
    id: 2,
    content: 'Check account balance',
    completed: true,
  },
];

export default function HardCodedValues() {
  const [todos, setTodos] = useState<Todo[]>(initialData);
  const [content, setContent] = useState('');

  return (
    <section>
      {todos.map((todo) => (
        <div key={todo.id}>{todo.content}</div>
      ))}
      <form
        onSubmit={(e) => {
          e.preventDefault();

          if (todos.length === MAX_FREE_TIER) {
            console.log(`You need to sign in to add more than ${MAX_FREE_TIER} todos`);
            return;
          }

          if (SENSITIVE_WORDS.includes(content)) {
            console.log('Cannot save sensitive information');
            return;
          }

          setTodos((prev) => [
            ...prev,
            { id: prev.length + 1, content: content, completed: false },
          ]);

          setContent('');
        }}>
        <input
          name='content'
          placeholder='Type here'
          value={content}
          onChange={(event) => setContent(event.target.value)}
        />
        <button
          type='submit'
          className='py-2 px-4'>
          Submit
        </button>
      </form>
    </section>
  );
}
tsx

Estilos de diseño en componentes reutilizables

Trata de no agregar estilos de diseño a los componentes reutilizables.

En nuestro ejemplo, tenemos un componente de encabezado que se utiliza en varios lugares. Este componente podría tener problemas de espacio en algunos lugares (podría aparecer demasiado cerca de otro contenido).

Una solución ingenua al principio podría ser escribir estilos de diseño (por ejemplo, margen, flexbox, grids) en el componente reutilizable, pero eso podría causar más problemas con el resto del contenido.

Una segunda solución podría ser envolver el componente en un solo div y aplicar estilos allí para separar el encabezado del otro contenido.

import React from 'react';

type HeaderProps = {
  children: React.ReactNode;
};

const Header = ({ children }: HeaderProps) => {
  return <h1 className='text-3xl lg:text-6xl font-bold tracking-tight'>{children}</h1>;
};

export default function LayoutInReusableComponents() {
  return (
    <section className='flex flex-col items-center'>
      <div className='mb-4'>
        <Header>This is a nice header</Header>
      </div>
      <ul>
        <li>Task 1: Water the plants</li>
        <li>Task 2: Buy groceries</li>
        <li>Task 3: Finish reading the book</li>
      </ul>
    </section>
  );
}
tsx

Y otra solución es simplemente aceptar una prop de clase opcional dentro de nuestro componente de encabezado y mantener intactas todas las demás clases base.

import React from 'react';

type HeaderProps = {
  children: React.ReactNode;
  className?: string;
};

const Header = ({ children, className }: HeaderProps) => {
  return (
    <h1 className={`text-3xl lg:text-6xl font-bold tracking-tight ${className}`}>
      {children}
    </h1>
  );
};

export default function LayoutInReusableComponents() {
  return (
    <section className='flex flex-col items-center'>
      <Header className='mb-10'>This is a nice header</Header>
      <ul>
        <li>Task 1: Water the plants</li>
        <li>Task 2: Buy groceries</li>
        <li>Task 3: Finish reading the book</li>
        <li>Task 4: Call Mom</li>
        <li>Task 5: Schedule a dentist appointment</li>
      </ul>
    </section>
  );
}
tsx

Manteniendo los componentes reutilizables simples

Imagina que tenemos un componente padre que gestiona una lista de productos y queremos mostrar el precio total de todos los productos.

Creamos un componente hijo ProductSummary que resume el precio total de los productos.

'use client'; // used only for nextjs

import React, { useState } from 'react';

type Product = {
  id: number;
  name: string;
  price: number;
};

function ProductSummary({ products }: { products: Product[] }) {
  // Derived state: Calculate the total price of all products
  const totalPrice = products.reduce((total, product) => total + product.price, 0);

  return (
    <>
      <h3>Summary</h3>
      <p>Total Products: {products.length}</p>
      <p>Total Price: ${totalPrice}</p>
    </>
  );
}

export default function ProductList() {
  const [products] = useState([
    { id: 1, name: 'Laptop', price: 999 },
    { id: 2, name: 'Smartphone', price: 499 },
    { id: 3, name: 'Tablet', price: 299 },
  ]);

  return (
    <section>
      <h2>Product List</h2>
      <ul>
        {products.map((product) => (
          <li key={product.id}>
            {product.name} - ${product.price}
          </li>
        ))}
      </ul>
      {/* Passing the entire list of products to the child component */}
      <ProductSummary products={products} />
    </section>
  );
}
tsx

Ahora, ¿qué podría ser una mala práctica en este caso?

Por un lado, si el componente de resumen obtiene un estado que causa un re-render, el precio total se recalculará incluso si no es necesario.

Otro problema podría ser que ahora el componente hijo está estrechamente acoplado con la estructura del padre y el arreglo de productos. Si quisiéramos reutilizar el componente en una página o componente diferente, tendríamos que cambiar la lógica del componente hijo, lo que lo haría más difícil de mantener.

Un enfoque mejor sería calcular el resumen en el componente padre. El componente hijo ahora solo necesita mostrar los datos que recibe, haciéndolo más simple y enfocado en la presentación.

'use client'; // used only for nextjs

import React, { useState } from 'react';

function ProductSummary({
  totalPrice,
  productCount,
}: {
  totalPrice: number;
  productCount: number;
}) {
  return (
    <>
      <h3>Summary</h3>
      <p>Total Products: {productCount}</p>
      <p>Total Price: ${totalPrice}</p>
    </>
  );
}

export default function ProductList() {
  const [products] = useState([
    { id: 1, name: 'Laptop', price: 999 },
    { id: 2, name: 'Smartphone', price: 499 },
    { id: 3, name: 'Tablet', price: 299 },
  ]);

  // Calculate total price in the parent component
  const totalPrice = products.reduce((total, product) => total + product.price, 0);

  return (
    <section>
      <h2>Product List</h2>
      <ul>
        {products.map((product) => (
          <li key={product.id}>
            {product.name} - ${product.price}
          </li>
        ))}
      </ul>
      {/* Pass the calculated total price as a prop */}
      <ProductSummary
        totalPrice={totalPrice}
        productCount={products.length}
      />
    </section>
  );
}
tsx

Este es solo uno de muchos casos de uso, a veces tener un estado derivado en un componente hijo está bien. Un componente hijo puede tener su propia lógica y puede ser más fácil razonar sobre su comportamiento sin tener que considerar el estado o la lógica del padre.

Un componente padre podría tener muchos estados que gestionar, y un componente hijo ayudará a manejar la lógica específica y llevar a una mejor separación del código.

Patrón de "Children Prop"

Analicemos el siguiente ejemplo:

import React from 'react';

const Grandchild = ({ message }: { message: string }) => {
  return <div>{message}</div>;
};

const Child = ({ message }: { message: string }) => {
  return (
    <div className="flex flex-col">
      <h1>This is a child component</h1>
      <Grandchild message={message} />
    </div>
  );
};

export default function Container() {
  return (
    <section className='container'>
      <Child message='Hello world!'></Child>
    </section>
  );
}
tsx

En este ejemplo, podemos ver que el componente Grandchild no depende de ninguna variable local dentro del componente Child, y la prop message simplemente se pasa y el componente Child no la necesita.

El patrón de children nos permite pasar componentes o datos directamente desde un componente padre a componentes intermedios sin que estos necesiten saber sobre ello.

La prop children es una prop especial en React. Automáticamente llena el contenido entre las etiquetas de apertura y cierre del componente.

Así es como puedes refactorizar el ejemplo anterior:

'use client'; // used only for nextjs

import React from 'react';

const Grandchild = ({ message }: { message: string }) => {
  console.log('Grandchild re-render');
  return <div>{message}</div>;
};

const Child = ({ children }: { children: React.ReactNode }) => {
  const [count, setCount] = React.useState(0);
  console.log('Child re-render');

  return (
    <div className='flex flex-col'>
      <h1>This is a child component</h1>
      <p>{count}</p>
      <button onClick={() => setCount(count + 1)}>Click me</button>
      {children}
    </div>
  );
};

export default function Container() {
  return (
    <section className='container'>
      <Child>
        <Grandchild message='Hello world!' />
      </Child>
    </section>
  );
}
tsx

Este patrón reduce la necesidad de pasar props a través de múltiples capas, haciendo el código más fácil de mantener y los componentes más reutilizables.

También trae beneficios de rendimiento; en nuestro ejemplo, el componente Grandchild no se volverá a renderizar cuando el componente Child se renderice de nuevo, porque no forma parte del árbol de componentes de Child. Grandchild pertenece al componente Container.

Debemos tener en cuenta que este patrón solo funciona cuando los elementos hijos no dependen de las variables internas de sus padres.

Una pequeña desventaja puede ser que el componente contenedor se vuelva más pesado.

Memoización o "Memoization"

Cada vez que los componentes se vuelven a renderizar cuando cambia el estado/las props, dependiendo de lo complicado que sea o si se necesita un cálculo costoso, esa renderización puede ser lenta.

Para ayudarnos con esta situación, React nos proporciona useMemo, useCallback y React.memo.

Imagina que tenemos un componente que muestra usuarios, podríamos tener cientos de usuarios para mostrar, por lo que cada vez que los usuarios cambian, se activará un rerender y también todas las declaraciones del componente se ejecutarán nuevamente.

'use client'; // used only for nextjs

import React, { useState } from 'react';

const allUsers = [
  { id: 1, active: true, name: 'Maria' },
  { id: 2, active: true, name: 'John' },
  { id: 3, active: true, name: 'Nataly' },
];

const ToggleUserStatus = ({ toggleUser }: { toggleUser: () => void }) => {
  return (
    <button
      className='px-4 py-2 bg-orange-400' onClick={toggleUser}>
      Toggle user
    </button>
  );
};

type User = {
  id: number;
  active: boolean;
  name: string;
};

const ShowUsers = ({ users, activeUsers }: { users: User[]; activeUsers: number }) => {
  return (
    <>
      <p>Active users: {activeUsers.toFixed(1)}%</p>
      {users.map((user) => (
        <p key={user.id}>
          {user.name}: {user.active + ''}
        </p>
      ))}
    </>
  );
};

export default function Memoization() {
  const [count, setCount] = useState(0);
  const [users, setUsers] = useState(allUsers);

  // This computation will run on every rerender
  const activeUsers = (users.filter((user) => user.active).length / users.length) * 100;

  // A new function is created and assigned to this variable on each rerender
  const toggleUser = () => {
    const randomId = Math.floor(Math.random() * users.length);

    const updatedUser = users.map((user, index) =>
      index === randomId ? { ...user, active: !user.active } : user
    );
    setUsers(updatedUser);
  };

  return (
    <section>
      <p>count: {count}</p>
      <button
        className='px-4 py-2 bg-slate-500'
        onClick={() => setCount((prev) => prev + 1)}>
        Increment {count}
      </button>
      <ToggleUserStatus toggleUser={toggleUser} />
      <ShowUsers
        users={users}
        activeUsers={activeUsers}
      />
    </section>
  );
}
tsx

Para evitar que cálculos costosos se ejecuten cada vez, podemos usar el hook useMemo. Se usa a menudo para cálculos, arrays de objetos o estados derivados (valores derivados de un estado existente).

También podemos usar el hook useCallback para funciones; useCallback es azúcar sintáctico. Existe únicamente para hacer nuestras vidas un poco más agradables al intentar memoizar funciones de callback.

En nuestro ejemplo, se crea una nueva función y se asigna a una variable en cada rerender, pero al envolverla en useMemo, la función se almacenará en caché.

Y por último, también podemos memoizar componentes usando memo. Si las props no cambian, el componente se almacenará en caché y no se volverá a renderizar. En nuestro ejemplo, dado que estamos usando useCallback en la función toggleUser, la función permanecerá igual entre rerenders y, por lo tanto, el componente ToggleUserStatus se mantendrá igual.

'use client';

import React, { memo, useCallback, useMemo, useState } from 'react';

const allUsers = [
  { id: 1, active: true, name: 'Maria' },
  { id: 2, active: true, name: 'John' },
  { id: 3, active: true, name: 'Nataly' },
];

const ToggleUserStatus = memo(function ToggleUser({
  toggleUser,
}: {
  toggleUser: () => void;
}) {
  return (
    <button
      className='px-4 py-2 bg-orange-400'
      onClick={toggleUser}>
      Toggle user
    </button>
  );
});

type User = {
  id: number;
  active: boolean;
  name: string;
};

const ShowUsers = memo(function ShowUsers({
  users, activeUsers,
}: {
  users: User[]; activeUsers: number;
}) {
  return (
    <>
      <p>Active users: {activeUsers.toFixed(1)}%</p>
      {users.map((user) => (
        <p key={user.id}>
          {user.name}: {user.active + ''}
        </p>
      ))}
    </>
  );
});

export default function Memoization() {
  const [count, setCount] = useState(0);
  const [users, setUsers] = useState(allUsers);

  // This computation will run on every rerender without useMemo
  const activeUsers = useMemo(
    () => (users.filter((user) => user.active).length / users.length) * 100,
    [users]
  );

  // A new function is created and assigned to this variable on each rerender without useCallback
  const toggleUser = useCallback(() => {
    const randomId = Math.floor(Math.random() * users.length);
    const updatedUser = users.map((user, index) =>
      index === randomId ? { ...user, active: !user.active } : user
    );
    setUsers(updatedUser);
  }, [users]);

  return (
    <section>
      <p>count: {count}</p>
      <button
        className='px-4 py-2 bg-slate-500'
        onClick={() => setCount((prev) => prev + 1)}>
        Increment {count}
      </button>
      <ToggleUserStatus toggleUser={toggleUser} />
      <ShowUsers
        users={users}
        activeUsers={activeUsers}
      />
    </section>
  );
}
tsx

Al hacer clic en el botón de incremento en el componente padre, no se activa un rerender en los dos componentes hijos.

Nota: El uso de estos hooks podría cambiar en el futuro. El equipo de React está investigando activamente si es posible “auto-memoizar” el código durante el paso de compilación. Todavía está en fase de investigación.

Función actualizadora de useState

Si tenemos un estado que está cambiando y depende del valor anterior, debemos usar la forma funcional de establecer el estado. Por ejemplo:"

setUsers((prev) => [...prev, { id: 4, active: true, name: "Mike" }]);
tsx

Queremos mantener los valores anteriores extendiéndolos y agregar o actualizar el nuevo valor.

Al hacerlo de esta manera, evitamos problemas con valores antiguos que se renderizan en lugar de los nuevos esperados.

'use client';

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

const allUsers = [
  { id: 1, active: true, name: 'Maria' },
  { id: 2, active: true, name: 'John' },
  { id: 3, active: true, name: 'Nataly' },
];

export default function SetterFunction() {
  const [count, setCount] = useState(0);
  const [users, setUsers] = useState(allUsers);

  useEffect(() => {
    setUsers((prev) => [...prev, { id: 4, active: true, name: 'Mike' }]);

    const intervalId = setInterval(() => {
      setCount((prev) => prev + 1);

      // Doing it like this will not work
      // setCount(count + 1);
      // setCount(count + 1);
      // setCount(count + 1);
    }, 1000);

    return () => {
      clearInterval(intervalId);
    };
  }, []);

  const handleClick = () => {
    setCount((prev) => prev + 1);
    setCount((prev) => prev + 1);
  };

  const handleClickAsync = () => {
    setTimeout(() => {
      setCount((prev) => prev + 1);
    }, 1000);
  };

  return (
    <section>
      <p>{count}</p>
      <div>
        {users.map((user) => (
          <p key={user.id}>{user.name}</p>
        ))}
      </div>
      <button
        onClick={handleClick}
        className='bg-blue-500 px-4 py-2 text-white rounded'>
        Add two more
      </button>
      <button
        onClick={handleClickAsync}
        className='bg-blue-500 px-4 py-2 text-white rounded'>
        Add number after 1 sec
      </button>
    </section>
  );
}
tsx

Estado Único

En lugar de tener múltiples estados para características o lógicas estrechamente relacionadas, considera usar un solo estado.

En nuestro ejemplo, si tenemos estados de carga, error y éxito, pueden unirse en un solo estado.

'use client';

import React, { useState } from 'react';

type Status = 'init' | 'loading' | 'error' | 'success';

export default function UnionTypes() {
  const [status, setStatus] = useState<Status>('init');
  return <section>{status}</section>;
}
tsx

Una única fuente de Verdad

Lleva un seguimiento de los ítems activos o seleccionados por su id y no copiando el ítem completo.

Podríamos sentirnos tentados a copiar un ítem completo al seleccionar o alternar un ítem en una lista. Pero si ese ítem cambia en el futuro, tendremos dos fuentes de verdad diferentes. Estarán desincronizadas y necesitaremos actualizar ambos estados para mantenerlos sincronizados.

Si necesitamos el ítem completo, simplemente podemos derivar el estado y obtener el objeto completo.

'use client';

import React, { useState } from 'react';

type User = {
  id: number;
  name: string;
};

export default function SingleTruth() {
  const [users, setUsers] = useState([
    { id: 1, name: 'Nataly' },
    { id: 2, name: 'John' },
    { id: 3, name: 'Alice' },
  ]);

  const [activeUser, setActiveUser] = useState<number | null>(null);

  const selectedUser = users.find((user) => user.id === activeUser);

  const handleSelectUser = (user: User) => {
    setActiveUser(user.id);
  };

  return (
    <div>
      <h2>Select a User</h2>
      {users.map((user) => (
        <button
          className='bg-slate-500 px-4 py-2 m-1'
          key={user.id}
          onClick={() => handleSelectUser(user)}>
          {user.name}
        </button>
      ))}

      {selectedUser ? (
        <div>
          <h3>Active User:</h3>
          <p>{selectedUser.name}</p>
        </div>
      ) : (
        <div>
          <h3>No user selected</h3>
        </div>
      )}
    </div>
  );
}
tsx

Usa parámetros de URL en lugar de estado

Cuando tenemos información como filtros, variantes o paginación, colocar la información en la URL puede ser una mejor opción; incluso podemos compartir la URL y obtener la vista exacta que estábamos mirando. Además de compartir, las personas también podrían marcar la página y regresar a la misma vista.

En nuestro ejemplo con Next.jsExternal Link, estamos configurando un parámetro de URL usando una entrada; luego podríamos usar esta información para filtrar una tabla.

'use client';

import React from 'react';
import { useSearchParams, usePathname, useRouter } from 'next/navigation';

export default function Search() {
  const searchParams = useSearchParams();
  const pathname = usePathname();
  const { replace } = useRouter();

  function handleSearch(term: string) {
    const params = new URLSearchParams(searchParams);
    if (term) {
      params.set('query', term);
    } else {
      params.delete('query');
    }
    replace(`${pathname}?${params.toString()}`);
  }
  return (
    <input
      placeholder={'search'}
      onChange={(e) => {
        handleSearch(e.target.value);
      }}
      defaultValue={searchParams.get('query')?.toString()}
    />
  );
}
tsx