How to Work With React the Right Way: An Expert Developer‘s Guide

React has taken the frontend world by storm since its initial release in 2013. It‘s now one of the most popular and widely-used JavaScript libraries, with over 7.4 million weekly downloads on NPM as of March 2023^1^. Its component-based model and declarative approach to UI development has influenced the design of numerous other frameworks.

But with great popularity comes great responsibility. As React applications scale in size and complexity, it becomes increasingly important to follow best practices and avoid common pitfalls. In this guide, we‘ll dive deep into what it takes to work with React effectively from the perspective of a seasoned full-stack developer.

Understanding React‘s Mental Model

To work with React effectively, it‘s crucial to understand how it approaches the problem of building user interfaces. At its core, React is a library for creating reusable UI components. These components encapsulate both the structure (HTML) and behavior (JavaScript) of a piece of the interface.

React components are typically written in JSX, a syntax extension that allows HTML-like code to be written directly in JavaScript. Here‘s a simple example:

function Greeting({ name }) {
  return ;
}

When this component is rendered, React will create an h1 element with the text "Hello, [name]!", where [name] is the value passed to the component via props.

This might seem like a trivial example, but it illustrates a key aspect of React‘s design: the UI is a function of the data passed to it. When the data changes, React automatically re-renders the component with the new data.

This declarative approach is a shift from the imperative style of UI development, where changes are made by directly manipulating the DOM. With React, you simply describe what the UI should look like for a given state, and let React handle the details of updating the DOM efficiently.

Keeping Components Small and Focused

As applications grow, it‘s easy for components to become bloated and take on too many responsibilities. This makes them harder to reason about, test, and maintain. A good rule of thumb is the Single Responsibility Principle: a component should do one thing and do it well.

In practice, this means breaking down large components into smaller, reusable pieces. For example, consider a component that renders a list of items with pagination:

function ItemList({ items }) {
  const [currentPage, setCurrentPage] = useState(1);
  const itemsPerPage = 10;

  const handlePageChange = (newPage) => {
    setCurrentPage(newPage);
  };

  const startIndex = (currentPage - 1) * itemsPerPage;
  const endIndex = startIndex + itemsPerPage;
  const currentItems = items.slice(startIndex, endIndex);

  return (
    <div>
      <ul>
        {currentItems.map(item => (
          <li key={item.id}>{item.name}</li>  
        ))}
      </ul>
      <Pagination
        currentPage={currentPage}
        totalPages={Math.ceil(items.length / itemsPerPage)}
        onPageChange={handlePageChange}
      />
    </div>
  );
}

This component has several responsibilities:

  1. Rendering the list of items
  2. Paginating the items
  3. Keeping track of the current page
  4. Handling page changes

A better approach would be to split this into separate components:

function ItemList({ items }) {
  return (
    <ul>  
      {items.map(item => (
        <li key={item.id}>{item.name}</li>
      ))}
    </ul>
  );
}

function PaginatedList({ items, itemsPerPage }) {
  const [currentPage, setCurrentPage] = useState(1);

  const handlePageChange = (newPage) => {    
    setCurrentPage(newPage);
  };

  const startIndex = (currentPage - 1) * itemsPerPage;
  const endIndex = startIndex + itemsPerPage;   
  const currentItems = items.slice(startIndex, endIndex);

  return (
    <div>
      <ItemList items={currentItems} />
      <Pagination  
        currentPage={currentPage}
        totalPages={Math.ceil(items.length / itemsPerPage)}
        onPageChange={handlePageChange}
      />
    </div>
  );
}

Now the ItemList component is only responsible for rendering the items, while the PaginatedList component handles the pagination logic. This makes each component easier to understand and reuse.

Managing State Effectively

One of the most common pitfalls in React is mismanaging state. It‘s easy to fall into the trap of putting too much state in a component, or updating state in a way that causes unnecessary re-renders.

A good principle to follow is to keep state as localized as possible. State should be kept in the component where it‘s most relevant, and only lifted up to parent components when necessary for sharing between siblings.

For example, consider a form with multiple input fields:

function ContactForm() {
  const [name, setName] = useState(‘‘);
  const [email, setEmail] = useState(‘‘);
  const [message, setMessage] = useState(‘‘);

  const handleSubmit = (event) => {
    event.preventDefault();
    console.log({ name, email, message });  
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        placeholder="Name"   
        value={name}
        onChange={(e) => setName(e.target.value)}
      />
      <input  
        type="email"
        placeholder="Email"
        value={email} 
        onChange={(e) => setEmail(e.target.value)}
      />
      <textarea
        placeholder="Message"
        value={message}
        onChange={(e) => setMessage(e.target.value)}
      />
      <button type="submit">Send</button>
    </form>
  );
}

In this case, the state of each input field is managed by the ContactForm component. This is fine for a small form, but as the form grows in complexity, it can become cumbersome to manage all the state in one place.

A better approach might be to create a reusable Input component that manages its own state:

function Input({ label, value, onChange }) {
  return (
    <div>
      <label>{label}</label>
      <input
        type="text"
        value={value}
        onChange={(e) => onChange(e.target.value)}
      />
    </div>
  );  
}

function ContactForm() {
  const [name, setName] = useState(‘‘);
  const [email, setEmail] = useState(‘‘);
  const [message, setMessage] = useState(‘‘);

  const handleSubmit = (event) => {
    event.preventDefault();
    console.log({ name, email, message });
  };

  return (
    <form onSubmit={handleSubmit}>
      <Input label="Name" value={name} onChange={setName} />
      <Input label="Email" value={email} onChange={setEmail} />
      <textarea
        placeholder="Message"
        value={message}
        onChange={(e) => setMessage(e.target.value)}
      />
      <button type="submit">Send</button>
    </form>
  );
}

Now the ContactForm component is only responsible for managing the overall form state, while the Input component handles its own state. This makes the code more modular and reusable.

Avoiding Unnecessary Re-renders

One of the most common performance issues in React applications is unnecessary re-renders. By default, when a component‘s state or props change, React will re-render the component and all of its children. This is usually fine, but in some cases it can lead to performance problems.

To avoid unnecessary re-renders, you can use the shouldComponentUpdate lifecycle method (or the React.memo higher-order component) to tell React when a component needs to re-render. For example:

class TodoItem extends React.PureComponent {
  render() {
    return <li>{this.props.todo.text}</li>;
  }
}

In this case, TodoItem extends React.PureComponent, which implements shouldComponentUpdate with a shallow prop and state comparison. This means that TodoItem will only re-render if its props or state have actually changed.

You can also use the useMemo and useCallback hooks to memoize expensive computations and callbacks:

function TodoList({ todos }) {
  const sortedTodos = useMemo(() => {
    return todos.slice().sort((a, b) => {
      return a.text.localeCompare(b.text);
    });
  }, [todos]);

  const handleAddTodo = useCallback((text) => {
    // ...
  }, []);

  return (
    <div>
      <ul>
        {sortedTodos.map(todo => (
          <TodoItem key={todo.id} todo={todo} />
        ))}
      </ul>
      <AddTodo onAddTodo={handleAddTodo} />
    </div>
  );
}

In this example, useMemo is used to memoize the expensive sortedTodos computation, so it‘s only re-computed when the todos prop actually changes. Similarly, useCallback is used to memoize the handleAddTodo callback, so it‘s not re-created on every render.

Using the React Profiler

The React Profiler is a powerful tool for identifying performance bottlenecks in your application. It allows you to record a performance trace of your application and see which components are taking the longest to render.

To use the Profiler, you first need to install the react-dom package:

npm install react-dom

Then you can import the Profiler component from react-dom/profiler:

import { Profiler } from ‘react-dom/profiler‘;

You can then wrap any part of your application with the Profiler component:

function App() {
  return (
    <Profiler id="App" onRender={callback}>
      {/* ... */}
    </Profiler>
  );
}

The onRender callback will be called every time a component within the Profiler tree "commits" an update. It receives parameters describing what was rendered and how long it took.

You can use this information to identify which components are taking the longest to render, and optimize them accordingly. For example, you might memoize expensive computations, split components into smaller pieces, or use lazy loading to defer the loading of non-critical code.

Staying Up-to-Date with the React Ecosystem

The React ecosystem is constantly evolving, with new features, libraries, and best practices emerging all the time. To be an effective React developer, it‘s important to stay up-to-date with these changes.

Some of the key resources to follow include:

  • The official React blog and documentation
  • Popular React newsletters like React Status and This Week in React
  • Key voices in the React community on Twitter, such as Dan Abramov, Ryan Florence, and Kent C. Dodds
  • Major React conferences like ReactConf and React Rally

It‘s also a good idea to regularly review your application‘s dependencies and update them to the latest versions when possible. This can help you stay on top of important bug fixes and performance improvements.

Conclusion

Working with React effectively requires a deep understanding of its component model, state management, and performance characteristics. By following best practices like keeping components small and focused, managing state effectively, and avoiding unnecessary re-renders, you can build high-quality, maintainable React applications.

But mastering React is an ongoing journey. As the library and its ecosystem continue to evolve, it‘s crucial to stay curious, keep learning, and adapt your practices accordingly. With the right mindset and a commitment to continuous improvement, you can harness the full power of React to build amazing user experiences.

Similar Posts