React Best Practices - Part 4

React Best Practices
React Best Practices

In this fourth part of our React Best Practices series, we continue exploring techniques and patterns that can take your React applications to the next level. As applications grow in complexity and size, maintaining performance, readability, and accessibility becomes even more critical. By following proven practices, developers can streamline development, enhance user experience, and create applications that are both scalable and maintainable.

Whether you're an experienced developer or just getting comfortable with React, these best practices will help you build applications that are fast, reliable, and easy to maintain. Let’s dive into some of the more advanced techniques that can make a significant impact on your React projects!

Functional state updates

Functional state updates are crucial in scenarios where state updates are asynchronous or when multiple updates are batched. React may batch multiple setState calls for optimization, which can lead to unexpected results if you rely on the outdated state.

Example: Counter Without Functional State Update (Potential Bug)If you update a counter state without a functional update, multiple clicks on a button that increment the counter may result in unexpected values:

Bad Example:

import React, { useState } from "react";

const Counter = () => {
  const [count, setCount] = useState(0);

  const increment = () => {
    setCount(count + 1);
    setCount(count + 1);
  };

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={increment}>Increment</button>
    </div>
  );
};

export default Counter;

In this example, you might expect increment to increase count by 2, but it may only increment by 1 because React batches updates. The count in each setCount(count + 1) refers to the initial count value at the beginning of the function, not the updated state after the first setCount.

Correct Way: Using Functional State UpdateBy using a functional state update, you ensure that each update works on the latest state, avoiding the problem of stale state values:

Good Example:

import React, { useState } from "react";

const Counter = () => {
  const [count, setCount] = useState(0);

  const increment = () => {
    setCount((prevCount) => prevCount + 1);
    setCount((prevCount) => prevCount + 1); // now updates correctly
  };

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={increment}>Increment</button>
    </div>
  );
};

export default Counter;
  • Functional State Update: setCount((prevCount) => prevCount + 1)uses a callback function that receives the most up-to-date state (prevCount) as an argument, ensuring that state updates are accurate even when they depend on previous state values.
  • Avoids Stale State Issues: Since each call to setCount receives the current count value, you avoid issues where multiple updates are batched or where state is changed asynchronously.

The functional state update pattern is recommended whenever your state update depends on the previous state. It ensures that you’re always working with the latest version of the state, improving reliability, especially in situations with complex or multiple state updates.

State Initializer Pattern

The State Initializer Pattern in React involves providing an initializer function to set the initial state of a component. Rather than directly setting a static initial state, this function allows for a more dynamic approach, often used when the initial state depends on props or other computed values. This pattern is useful for setting up complex initial states or preventing unnecessary re-renders when initializing state with props.

Benefits

  • Efficient State Initialization: Prevents unnecessary computations or operations when setting initial state, especially if the state needs to be derived from props.
  • Lazy Initialization: Only computes the initial state once, even if the function is complex, reducing resource usage and improving performance.
  • Flexible Initial State: Allows for a more adaptable initial state that can depend on props or other data without triggering re-renders.

import React, { useState } from "react";

const Counter = ({ initialCount = 0 }) => {
  // State initializer function to set initial count
  const [count, setCount] = useState(() => {
    console.log("Setting initial count");
    return initialCount;
  });

  const increment = () => setCount(count + 1);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={increment}>Increment</button>
    </div>
  );
};

// Usage
const App = () => {
  return <Counter initialCount={5} />;
};

export default App;

The Extensible Styles Pattern

The Extensible Styles Pattern is a design approach in React (or any component-based framework) that enables flexible, customizable styling for components by allowing consumers to extend or override styles. This pattern is especially useful when building component libraries or reusable UI components, as it allows developers to maintain a consistent design system while giving end-users control over component appearance.

Benefits of the Extensible Styles Pattern

  • Customizability: Allows consumers to adjust component styles according to specific needs or design requirements without altering the base component.
  • Consistency: Maintains a consistent style base while offering flexibility, making it ideal for design systems and UI libraries.
  • Ease of Maintenance: Component developers can define core styles while delegating style customizations to the component’s consumers.

// Button.module.css
.defaultButton {
  padding: 10px 20px;
  background-color: blue;
  color: white;
}

// Button.js
import styles from "./Button.module.css";

const Button = ({ children, className }) => (
  <button className={`${styles.defaultButton} ${className}`}>{children}</button>
);

// Usage
<Button className="myCustomClass">Click Me</Button>

Atomic Design

Atomic Design is a methodology created by Brad Frost for designing and building user interfaces in a structured, scalable way. Inspired by chemistry, it breaks down complex UIs into a hierarchy of simple, reusable components, referred to as "atoms," "molecules," "organisms," "templates," and "pages." This approach promotes reusability, consistency, and maintainability in design systems and front-end development.

Atomic

  • Atoms are the basic building blocks of an interface, representing the smallest, indivisible elements. Examples include buttons, input fields, labels, and icons.
  • Atoms are often styled and functional on their own but are designed to be reused and combined into more complex elements.

// Example Atom: Button
const Button = ({ label, onClick }) => (
  <button onClick={onClick}>{label}</button>
);

Molecules

  • Molecules are combinations of atoms that work together to form a distinct UI element. For example, an input field (atom) and a label (atom) might combine to form a search box molecule.
  • Molecules typically encapsulate basic functionality and styling, representing a small unit of interaction.

// Example Molecule: SearchBox
const SearchBox = ({ value, onChange, onSearch }) => (
  <div>
    <input type="text" value={value} onChange={onChange} />
    <Button label="Search" onClick={onSearch} />
  </div>
);

Organisms

  • Organisms are more complex structures, composed of groups of atoms and molecules working together to create a distinct, reusable section of a page. Examples include a navbar, footer, or card layout.
  • Organisms define larger, reusable chunks of a UI that have a clear purpose and behavior.

// Example Organism: Header
const Header = () => (
  <header>
    <Logo />
    <NavMenu />
    <SearchBox />
  </header>
);

Templates

  • Templates are page-level layouts that define the structure of content, combining various organisms in a cohesive layout.
  • Templates establish the overall hierarchy and spatial layout but typically don’t contain actual content — they serve as a placeholder for content that will be populated on specific pages.

// Example Template: MainTemplate
const MainTemplate = ({ header, content, footer }) => (
  <div>
    {header}
    <main>{content}</main>
    {footer}
  </div>
);

Pages

  • Pages are instances of templates that contain real, specific content. Pages represent the final UI that users interact with and experience.
  • Pages combine templates with specific data, resulting in unique pages for each route or view in an application.

// Example Page: HomePage
const HomePage = () => (
  <MainTemplate
    header={<Header />}
    content={<HomeContent />}
    footer={<Footer />}
  />
);

Benefits of Atomic Design

  • Reusability: Smaller, reusable components (atoms, molecules) can be combined in various ways to build complex UIs, reducing duplication and improving consistency.
  • Consistency: Each component follows the same design principles, ensuring a uniform look and feel across the application.
  • Scalability: By structuring components hierarchically, Atomic Design scales well for large applications or design systems, making it easier to manage and extend.
  • Modularity: Components are isolated and self-contained, allowing developers to make changes at any level without affecting unrelated parts of the application.
  • Maintainability: With clear component roles and structure, maintaining and updating the UI becomes easier, particularly as the codebase grows.

Atomic Design provides a systematic way to build UIs by breaking down the interface into reusable, consistent components at different levels. Tools like Storybook align perfectly with this methodology, offering a platform to document, test, and visualize these components in isolation. Together, Atomic Design and Storybook empower teams to build scalable and maintainable design systems with a clear focus on reusability and consistency.

Additional State and Event Tools: EventEmitter3, PubSubJS, and Observables

EventEmitter3

When managing state and events in React, several tools offer different approaches to decoupled communication, helping to keep components modular and responsive. EventEmitter3 is a lightweight, efficient library designed for in-process event handling, providing a simple way to trigger and listen to custom events within a single JavaScript runtime. Though ideal for handling asynchronous operations in Node.js or isolated React components, EventEmitter3 lacks network capabilities, so it's best suited for in-app, component-level communication rather than cross-client/server scenarios.


import React, { useEffect } from 'react';
import EventEmitter from 'eventemitter3';

// Create an instance of EventEmitter
const eventEmitter = new EventEmitter();

export default function EventEmitterExample() {
  useEffect(() => {
    // Listener for the 'customEvent'
    const handleCustomEvent = (message) => {
      alert(`Received event with message: ${message}`);
    };

    eventEmitter.on('customEvent', handleCustomEvent);

    // Cleanup listener on component unmount
    return () => {
      eventEmitter.off('customEvent', handleCustomEvent);
    };
  }, []);

  const emitEvent = () => {
    // Emit 'customEvent' with a message
    eventEmitter.emit('customEvent', 'Hello from EventEmitter3!');
  };

  return (
    <div>
      <h1>EventEmitter3 in React</h1>
      <button onClick={emitEvent}>Emit Event</button>
    </div>
  );
}

PubSubJS

PubSubJS brings a publish-subscribe (pub-sub) pattern to JavaScript, allowing components to "publish" messages on topics and other components to "subscribe" to receive them. While lightweight and efficient for in-browser communication, it lacks the state persistence and structure offered by dedicated state management libraries like Redux or Recoil. It’s helpful for broadcasting events across non-related components, but overuse in React can create complex dependency chains, especially in larger applications.


import React, { useEffect, useState } from 'react';
import PubSub from 'pubsub-js';

export default function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    // Subscribe to the "INCREMENT" event
    const token = PubSub.subscribe('INCREMENT', () => {
      setCount((prevCount) => prevCount + 1);
    });

    // Clean up subscription on component unmount
    return () => {
      PubSub.unsubscribe(token);
    };
  }, []);

  // Function to publish the "INCREMENT" event
  const handleIncrement = () => {
    PubSub.publish('INCREMENT');
  };

  return (
    <div>
      <h1>Count: {count}</h1>
      <button onClick={handleIncrement}>Increment</button>
    </div>
  );
}

Observables

Finally, Observables (often managed via libraries like RxJS) offer a more sophisticated solution for event and data stream handling. Observables emit values over time, allowing for advanced transformations and complex data flows. When integrated with React, they’re well-suited for managing asynchronous operations, like continuous data fetching or real-time events, as they can handle multiple events in sequence or simultaneously.


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

const ObservableCounter = () => {
  const [count, setCount] = useState(0);

  useEffect(() => {
    // Create an Observable that emits values every second
    const counter$ = new Observable((observer) => {
      let currentCount = 0;
      const intervalId = setInterval(() => {
        observer.next(currentCount++);
      }, 1000);

      // Cleanup when the Observable is unsubscribed
      return () => clearInterval(intervalId);
    });

    // Subscribe to the Observable
    const subscription = counter$.subscribe((value) => {
      setCount(value);
    });

    // Cleanup subscription on component unmount
    return () => subscription.unsubscribe();
  }, []);

  return (
    <div>
      <h1>Observable Counter</h1>
      <p>Count: {count}</p>
    </div>
  );
};

export default ObservableCounter;

NextJS

Next.js, built on top of React, brings several performance-boosting features that make it a powerful framework for building modern web applications. Here’s what Next.js brings to the table to improve the performance of React:

  • Server-Side Rendering (SSR): Renders React components on the server and sends fully-rendered HTML to the client. Faster initial load times, especially for large apps or pages with dynamic content. Better SEO because search engines can index pre-rendered HTML.
    Best for: Dynamic content that needs to be updated on every request (e.g., dashboards, e-commerce).
  • Static Site Generation (SSG): Pre-renders pages at build time, creating static HTML files. Lightning-fast load times as the HTML is served directly from a CDN. Reduces server load since the content is pre-generated.
    Best for: For pages with content that doesn’t change often, like blogs, marketing pages, or documentation.
  • Incremental Static Regeneration (ISR): Combines SSG with dynamic revalidation, regenerating static pages at runtime without rebuilding the whole site. Ensures content freshness while maintaining the speed of static pages. Reduces server strain by regenerating only specific pages.
    Best for: For applications with frequently updated content, like product catalogs or news sites.
  • API Routes: Provides built-in serverless functions to create APIs directly within the Next.js app. Eliminates the need for a separate backend, reducing latency. Runs in a serverless environment for scalability and cost-efficiency.
    Best for: Lightweight data handling, like forms or dynamic queries.
  • Automatic Code Splitting: Automatically splits JavaScript bundles by route. Only loads the code needed for the current page, reducing the initial bundle size. Improves perceived performance by avoiding overloading the browser with unnecessary scripts.
  • Optimized Image Handling: Built-in next/image component optimizes images with lazy loading, responsive sizes, and WebP support. Improves page load times by only loading images as needed. Reduces bandwidth usage with efficient image formats.
    Best for: On any site with images.
  • React Server Components (RSC): Fetches and renders data on the server, sending lightweight HTML to the client. Reduces JavaScript sent to the client, improving load times. Makes hydration faster as fewer scripts need execution.
    Best for: On large applications with heavy data fetching requirements.
  • Automatic Static Optimization: Automatically detects pages that can be statically generated and serves them as static files. No configuration needed to get static page benefits. Optimizes mixed environments with both static and dynamic pages.
    Best for:
  • Edge Functions: Runs server-side logic at the edge, closer to users, for faster responses. Reduces latency for users by processing logic at a nearby CDN edge location.
    Best for: Personalization, A/B testing, or geo-specific content delivery.
  • Built-In Analytics: Provides insights into performance metrics like Largest Contentful Paint (LCP) and Total Blocking Time (TBT). Identifies bottlenecks and helps optimize critical paths.
    Best for: Continuous performance monitoring and optimization.

Next.js enhances React’s performance through features like SSR, SSG, ISR, code splitting, and image optimization. These tools help deliver faster, more efficient applications while reducing developer overhead. Incorporating Next.js into your React projects ensures both improved user experience and scalability.

VanilaJS vs React/NextJS

The performance comparison between Vanilla JavaScript and React + Next.js depends on the specific use case, complexity of the application, and development goals. Here’s a detailed breakdown:

Initial Load Time

Initial Load Time Faster initial load for simple applications due to no framework overhead. Direct DOM manipulation avoids extra abstractions.React + Next.jsSlightly slower for basic apps because of React's virtual DOM and Next.js's pre-rendering processes. Next.js with SSG or ISR can match or outperform Vanilla JS by pre-rendering pages and serving them from a CDN, resulting in lightning-fast static content delivery.

Vanilla JavaScript

Requires manual coding to fetch and render dynamic content. Efficient but can become cumbersome and error-prone as complexity increases.React + Next.js Built-in tools for SSR (Server-Side Rendering), ISR (Incremental Static Regeneration), and CSR (Client-Side Rendering) handle dynamic content efficiently. Improves perceived performance by preloading, lazy loading, and batching updates.

Scalability

Vanilla JavaScript Performance drops significantly as the application grows in complexity, making manual DOM updates harder to manage.React + Next.js Scales better for large apps by offering reusable components, state management, and optimized rendering techniques (e.g., React Server Components).

Developer Productivity

Vanilla JavaScript Suitable for small, simple projects but becomes increasingly difficult to maintain as complexity grows. Manual management of state, events, and updates can slow down development.React + Next.js Offers tools, abstractions, and conventions that speed up development and reduce boilerplate code. Features like API Routes, automatic code splitting, and React's state management streamline development for medium-to-large apps.

SEO and Accessibility

Vanilla JavaScript SEO requires manual handling (e.g., ensuring content is rendered server-side or pre-rendered). Accessibility and semantic HTML require meticulous attention to detail.React + Next.js Next.js provides SEO-friendly features like SSG and SSR, making it easy to deliver fully-rendered pages to search engines. Handles routing and meta tag updates seamlessly for better accessibility and discoverability.

Use Cases

When to Use Vanilla JavaScriptSmall, Static Websites: Landing pages, portfolios, or informational sites with minimal interactivity. Performance-Critical Apps: When every millisecond counts, and there’s no need for complex state management or advanced rendering techniques. Learning or Prototyping: Ideal for beginners learning core web technologies.When to Use React + Next.jsDynamic, Interactive Applications: Dashboards, e-commerce sites, social media platforms, or any app requiring frequent state changes. SEO and Content Updates: Blogs, news websites, or any content-driven site that benefits from ISR or SSG for performance and SEO. Scalable, Maintainable Projects: Apps expected to grow in complexity, requiring reusable components and optimized rendering.

Final Verdict

Vanilla JavaScript performs better for small, static, or very simple applications due to its minimal overhead.
React + Next.js excels in dynamic, scalable, or SEO-critical applications by leveraging powerful features like SSG, SSR, and ISR to balance performance and development efficiency.

When to Choose Each

Use CaseChoose Vanilla JSChoose React + Next.js
Small static websites
Performance-critical, minimal apps
Large-scale, dynamic applications
SEO and frequently updated content
Scalable, reusable component systems

Conclusion

In this fourth installment of our React Best Practices series, we’ve explored techniques and patterns that enhance the efficiency, scalability, and maintainability of React applications. From functional state updates to the state initializer pattern and extensible styles, these best practices are foundational for building robust applications in complex environments. As you continue your journey with React, these techniques will serve as valuable tools to optimize your code and deliver exceptional user experiences.

By incorporating methodologies like Atomic Design and leveraging advanced tools like EventEmitter3, PubSubJS, and Observables, developers can create modular, reusable, and dynamic components that meet the needs of modern web applications. As React applications grow in complexity, adhering to these practices ensures a balance between performance, maintainability, and user experience.

As you continue your journey in React development, remember that best practices are not one-size-fits-all. Adapt these techniques to your specific project needs, and strive to create solutions that are both efficient and future-proof. Stay tuned for more insights in the next part of the series!