React Best Practices - Part 3

React Best Practices
React Best Practices

In modern web development, creating high-performance, user-friendly, and accessible applications is essential. To achieve this, developers rely on best practices that optimize how data is fetched, components are rendered, and user interactions are handled. In this article, we will explore key techniques and tools, including Virtualization, Optimistic Updates, Tanstack React Query, Accessibility (a11y) Checks, and Debounce & Throttling. These strategies are designed to enhance application performance, ensure smooth user experiences, and maintain accessibility standards. Implementing these best practices will allow you to build robust and efficient React applications that meet the demands of modern users.

Virtualization

Virtualization is a performance optimization technique used to efficiently render large lists of data by only rendering the items visible in the viewport. Instead of rendering the entire list of thousands of elements at once, virtualization ensures that only the elements currently visible to the user are rendered, which dramatically improves performance by reducing the amount of work the browser has to do.

This is particularly useful when rendering large data sets in React applications, where rendering too many components at once can degrade performance and lead to slow load times or laggy user experiences.

When you use virtualization, your app keeps track of which items are visible in the viewport and dynamically renders or unmounts components as needed. This reduces the memory and rendering cost associated with large lists, as React only mounts and updates the components the user can see.

Popular libraries like react-window or react-virtualized provide ready-to-use solutions for virtualization in React apps.

Using react-window for Virtualization


npm install react-window
=====================================================
import React from 'react';
import { FixedSizeList as List } from 'react-window';

const MyBigList = ({ items }) => (
  <List
    height={400} // height of the viewport
    itemCount={items.length} // total number of items in the list
    itemSize={35} // height of each item
    width={300} // width of the viewport
  >
    {({ index, style }) => (
      <div style={style}>
        {items[index]} {/* Render only the visible items */}
      </div>
    )}
  </List>
);

const App = () => {
  const items = Array.from({ length: 1000 }, (_, index) => `Item ${index + 1}`);
  return <MyBigList items={items} />;
};

export default App;

  • The List component from react-window is used to render only the items that are visible in the viewport.
  • The height, width, and itemSize properties define the size of the viewport and the size of each item.
  • Instead of rendering all 1000 items at once, only a few items are rendered at a time, depending on the size of the viewport, greatly improving performance.

Benefits of Virtualization:

  • Improved Performance: By only rendering visible elements, you reduce the rendering workload, leading to smoother scrolling and faster load times for large lists.
  • Lower Memory Usage: Virtualization minimizes the memory footprint of your app by keeping only the necessary components in memory, preventing browser slowdowns.
  • Scalability: Virtualization allows your app to handle very large datasets without performance degradation, making it scalable even when dealing with thousands or millions of items.

When to Use Virtualization:

  • Rendering Large Lists: Virtualization is essential when you need to render a large number of items (e.g., a list of 10,000 users or transactions) and want to avoid rendering all the items at once.
  • Infinite Scrolling: In cases where users can scroll indefinitely, such as in social media feeds or product catalogs, virtualization ensures that only the visible content is rendered.

In conclusion, virtualization is a must-have technique for Frontend applications that deal with large data sets. It ensures that only the necessary items are rendered, leading to significant performance improvements and a better user experience, especially when scrolling through large lists.

Optimistic Updates

Optimistic updates are a user experience optimization technique in React (or any UI framework) where the UI is updated immediately with the expected result of an operation before receiving confirmation from the server. This creates a faster and more responsive experience for the user, as they see the changes reflected immediately, without waiting for the server response.

If the server request is successful, nothing changes, but if it fails, the UI can revert back to its previous state, or display an error message.

In a typical React app, after submitting data or performing an action that affects the state, the UI waits for the server's response before updating. This can result in noticeable delays in the user interface, especially with slow network connections. With optimistic updates, the app optimistically assumes the server request will succeed and updates the UI immediately, creating the appearance of instantaneous feedback.

Optimistic updates are especially useful in scenarios like updating a user's profile information, "liking" a post, or adding an item to a shopping cart.

Imagine a "like" button for a social media post. Instead of waiting for the server to confirm the "like" action, you can optimistically update the UI by toggling the "like" state immediately and revert it if the server request fails.


import React, { useState } from 'react';

const Post = ({ postId }) => {
  const [liked, setLiked] = useState(false);
  const [error, setError] = useState(null);

  const toggleLike = async () => {
    // Optimistically update the state
    setLiked(!liked);

    try {
      // Simulate server request
      const response = await fetch(`/api/posts/${postId}/like`, {
        method: 'POST',
        body: JSON.stringify({ liked: !liked }),
      });

      if (!response.ok) {
        throw new Error('Failed to like the post');
      }
    } catch (err) {
      // Revert the UI back to the previous state in case of error
      setLiked(!liked);
      setError(err.message);
    }
  };

  return (
    <div>
      <button onClick={toggleLike}>
        {liked ? 'Unlike' : 'Like'}
      </button>
      {error && <p style={{ color: 'red' }}>{error}</p>}
    </div>
  );
};

export default Post;
  • The liked state is updated immediately when the user clicks the "Like" button, without waiting for the server response.
  • If the server request fails, the UI reverts the liked state back to its original value and displays an error message.

Benefits of Optimistic Updates:

  • Faster User Feedback: Users see immediate feedback in the UI, making the app feel more responsive, even on slow networks.
  • Better User Experience: Optimistic updates create the perception of an application that responds instantly to user input, reducing frustration due to latency.
  • Reduced Perceived Latency: Optimistic updates help hide the time it takes for server requests to complete, improving the perceived performance of the app.

When to Use Optimistic Updates:

  • Quick, Non-Critical Updates: Optimistic updates are ideal for actions where the server response is highly likely to succeed, such as "liking" a post, updating a small piece of information, or deleting an item from a list.
  • Real-Time Applications: In real-time applications where instant feedback is crucial (e.g., chat apps, collaborative tools), optimistic updates can create a smoother user experience.

Considerations:

  • Error Handling: Always ensure you have proper error handling in place to revert the UI state in case of a failed server request.
  • Complex Updates: Optimistic updates work best for simpler state changes. For more complex actions, be cautious about the complexity of reverting the state if an error occurs.
In conclusion, optimistic updates enhance user experience by providing immediate feedback, making your React application feel faster and more responsive. However, it's essential to carefully handle errors and ensure the UI reverts if the server response doesn't match the optimistic expectation.

Debounce & Throttling

Debouncing and throttling are performance optimization techniques that control the frequency at which a function is executed. They are especially useful when dealing with events that are triggered frequently, such as scrolling, resizing, or typing in an input field. These techniques help prevent performance degradation by limiting the number of times a function is executed over a specific time frame.

  • Debouncing: Debounce delays the execution of a function until after a specified period has passed since the last time the function was invoked. It's useful for scenarios where you want to wait until a user has stopped performing an action (e.g., typing in a search bar) before executing a function.
  • Throttling: Throttle ensures that a function is called at most once during a specified interval, even if it is triggered multiple times. This is useful for events like scrolling or window resizing, where the function should only run periodically to avoid performance bottlenecks.

Imagine a search input field where a user is typing in real-time, and you want to avoid making API requests for each keystroke.


import React, { useState } from 'react';

const Post = ({ postId }) => {
  const [liked, setLiked] = useState(false);
  const [error, setError] = useState(null);

  const toggleLike = async () => {
    // Optimistically update the state
    setLiked(!liked);

    try {
      // Simulate server request
      const response = await fetch(`/api/posts/${postId}/like`, {
        method: 'POST',
        body: JSON.stringify({ liked: !liked }),
      });

      if (!response.ok) {
        throw new Error('Failed to like the post');
      }
    } catch (err) {
      // Revert the UI back to the previous state in case of error
      setLiked(!liked);
      setError(err.message);
    }
  };

  return (
    <div>
      <button onClick={toggleLike}>
        {liked ? 'Unlike' : 'Like'}
      </button>
      {error && <p style={{ color: 'red' }}>{error}</p>}
    </div>
  );
};

export default Post;
  • The handleSearch function is debounced by 500 milliseconds using the debounce function from the lodash library. This means the API request will only be made if the user stops typing for 500ms.
  • This prevents multiple API calls from being made for every keystroke, optimizing performance.

Suppose you want to monitor the window’s scroll position but avoid calling the scroll handler on every pixel the user scrolls.


import React, { useState } from 'react';

const Post = ({ postId }) => {
  const [liked, setLiked] = useState(false);
  const [error, setError] = useState(null);

  const toggleLike = async () => {
    // Optimistically update the state
    setLiked(!liked);

    try {
      // Simulate server request
      const response = await fetch(`/api/posts/${postId}/like`, {
        method: 'POST',
        body: JSON.stringify({ liked: !liked }),
      });

      if (!response.ok) {
        throw new Error('Failed to like the post');
      }
    } catch (err) {
      // Revert the UI back to the previous state in case of error
      setLiked(!liked);
      setError(err.message);
    }
  };

  return (
    <div>
      <button onClick={toggleLike}>
        {liked ? 'Unlike' : 'Like'}
      </button>
      {error && <p style={{ color: 'red' }}>{error}</p>}
    </div>
  );
};

export default Post;

The handleScroll function is throttled to execute at most once every 200 milliseconds. This ensures that the scroll handler doesn't fire excessively as the user scrolls, preventing performance bottlenecks.

When to Use Debouncing & Throttling:

  • Debouncing is ideal when you want to wait for user inactivity before executing a function. Common use cases include:
    • Search input fields (waiting for the user to finish typing before making an API request).
    • Resize events (waiting until the window resizing stops before recalculating layout).
  • Throttling is useful when you want to ensure that a function is executed at most once per specified interval. Common use cases include:
    • Scroll events (checking scroll position only once every few milliseconds).
    • Button clicks (limiting how often a button can be clicked).

Benefits of Debouncing & Throttling:

  • Improved Performance: By reducing the number of function calls, both debouncing and throttling prevent performance degradation in high-frequency events (e.g., scrolling, typing).
  • Reduced API Calls: Debouncing ensures that API calls are only made when necessary, reducing server load and network usage.
  • Smoother User Experience: Throttling smoothens performance-heavy interactions, such as scrolling or resizing, by limiting the rate of function execution.

Debouncing and throttling are essential techniques in React to manage event-heavy interactions efficiently. They improve performance and user experience by ensuring that functions are only executed when needed, preventing frequent updates and resource wastage.

Best fetching and global state management libraries

There are several popular libraries for fetching data and managing global state in React, each with its strengths. The "best" library depends on your specific requirements, such as the complexity of the app, the size of your codebase, and your familiarity with different tools. Below are some top choices:

Tanstack Query (formerly React Query)

Data fetching, caching, synchronization, and global state management for server-side state.

  • Automatic Caching: Automatically caches data, reducing unnecessary requests.
  • Automatic Refetching: Automatically refetches stale data when necessary (e.g., when the user revisits a page).
  • Error & Loading State Management: Built-in support for handling loading, error, and success states without writing additional logic.
  • Real-time Updates: Supports real-time features like WebSockets or polling.
  • DevTools: Provides a powerful DevTools integration to visualize and debug your queries.
When to Use:
  • Ideal for server-state-heavy applications.
  • If you want a library that simplifies data fetching, caching, and synchronization.
  • You need features like background fetching, pagination, and automatic re-fetching on window focus.

import { useQuery } from '@tanstack/react-query';

const fetchUserData = async () => {
  const response = await fetch('/api/user');
  return response.json();
};

const UserComponent = () => {
  const { data, isLoading, error } = useQuery(['userData'], fetchUserData);

  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error loading data</div>;

  return <div>User: {data.name}</div>;
};

Zustand

Simple, scalable state management solution with a minimal API.

  • Simplicity: Very easy to set up and use for managing global state.
  • Performance: Highly performant with shallow state updates and fine-grained selectors.
  • No Boilerplate: Minimal setup compared to more complex state management libraries.
  • Integration: You can integrate it with other libraries like React Query for fetching.
When to Use:
  • Ideal for local/global state management that doesn’t require the complexity of Redux or large state updates.
  • Small to medium-sized applications or when you prefer a simple, lightweight state management solution.

import create from 'zustand';

const useStore = create((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
}));

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

Redux Toolkit

Comprehensive global state management, often in larger applications.

  • Centralized State: Manages global state in a predictable, centralized manner.
  • Toolkit Simplification: Redux Toolkit simplifies Redux by reducing boilerplate code with features like slices and async thunks.
  • Middleware & DevTools: Strong integration with middleware like Redux-Saga or Thunk, and Redux DevTools for debugging.
  • Server-Side Data: Can be combined with Redux Thunk or Redux-Saga for managing side effects, including async requests.
When to Use:
  • Ideal for larger, complex applications with lots of state that need to be shared across components.
  • When you want predictable state management with a strict data flow (actions → reducers → store).

import { createSlice, configureStore } from '@reduxjs/toolkit';

const counterSlice = createSlice({
  name: 'counter',
  initialState: { count: 0 },
  reducers: {
    increment: (state) => {
      state.count += 1;
    },
  },
});

const store = configureStore({
  reducer: counterSlice.reducer,
});

const { increment } = counterSlice.actions;

store.dispatch(increment());
console.log(store.getState()); // { count: 1 }

Recoil

Global state management with a focus on React’s component architecture.

  • Minimal Boilerplate: Provides atom-based state management that integrates directly with React components.
  • Fine-grained Updates: Updates only the components that need new data, improving performance.
  • Scalability: Works well for medium-to-large apps, especially when state relationships are complex.
  • Async Selectors: Built-in support for asynchronous data fetching and derived state with selectors.
When to Use:
  • If you want a state management library that works seamlessly with React’s architecture.
  • Great for component-level state and apps that rely on derived state and async logic.

import { atom, useRecoilState } from 'recoil';

const countState = atom({
  key: 'countState',
  default: 0,
});

const Counter = () => {
  const [count, setCount] = useRecoilState(countState);
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
};

SWR (by Vercel)

Data fetching and caching with built-in revalidation.

  • Focus on Simplicity: SWR focuses purely on data fetching and caching with minimal setup.
  • Revalidation: Automatically re-fetches data in the background when it becomes stale (e.g., on page focus).
  • Optimistic Updates: Supports optimistic UI updates and pagination.
When to Use:
  • Ideal for server-side data fetching with built-in support for caching, revalidation, and focus-based fetching.
  • Perfect for smaller applications where a lightweight data fetching solution is needed.

import useSWR from 'swr';

const fetcher = (url) => fetch(url).then((res) => res.json());

const Profile = () => {
  const { data, error } = useSWR('/api/user', fetcher);

  if (error) return <div>Failed to load</div>;
  if (!data) return <div>Loading...</div>;

  return <div>Hello, {data.name}</div>;
};

Conclusion

  • Tanstack React Query or SWR is best for data fetching and managing server-side state.
  • Zustand or Recoil are excellent for simple global state management.
  • Redux Toolkit is a robust choice for complex global state management in larger applications.

If your primary focus is on server-side data (like fetching from an API), React Query or SWR is your best bet. If you need client-side global state management for more complex applications, Redux Toolkit might be the right tool. For simpler or smaller apps, Zustand or Recoil offer lightweight alternatives.

Accessibility (a11y)

Accessibility (often abbreviated as a11y) ensures that your React applications are usable by everyone, including people with disabilities. Following accessibility best practices improves the user experience for individuals with visual, auditory, or motor impairments and makes your app more inclusive. Accessibility involves making your UI work well with screen readers, providing keyboard navigation, supporting users with color blindness, and using semantic HTML for better context.

Semantic HTML Using proper HTML elements provides context to both users and assistive technologies (like screen readers). For example, using <button> for clickable elements or <header>, <footer>, <nav> for structure ensures the HTML is more readable and accessible.


// Bad: Using divs for everything
<div onClick={handleClick}>Click me</div>

// Good: Use semantic HTML
<button onClick={handleClick}>Click me</button>

Color Blindness About 1 in 12 men and 1 in 200 women are affected by color blindness. Avoid relying on color alone to convey meaning (e.g., red for error, green for success). Use text, symbols, or patterns in combination with color.


// Bad: Relying on color alone
<div style={{ color: 'red' }}>Error</div>

// Good: Adding text or icons along with color
<div style={{ color: 'red' }}>
  <span role="img" aria-label="error"></span> Error
</div>

Screen Readers Screen readers are assistive technologies that read the content of the webpage aloud for users with visual impairments. Properly structuring your HTML and adding ARIA (Accessible Rich Internet Applications) attributes can improve screen reader accessibility.

  • Add aria-label, aria-labelledby, and other ARIA attributes to improve the context for screen readers.
  • Use <label> for form elements so screen readers can associate the label with the input field.

// Accessible form field with label
<label htmlFor="username">Username</label>
<input type="text" id="username" aria-required="true" />

Keyboard Navigation Ensure that all interactive elements (e.g., buttons, links, form fields) are accessible using only the keyboard. Users should be able to navigate your app using Tab and Shift+Tab, and activate buttons or links using the Enter or Space keys.


// Ensure focus is visible when navigating with the keyboard
<button onClick={handleSubmit}>Submit</button>

Focus Management Managing focus is important for accessibility, especially after dynamic changes like form submissions or modals opening. Ensure focus is correctly placed on the next interactive element or returned to the appropriate element when modals are closed.


// Trap focus within a modal and return focus to the button when closed
const Modal = ({ isOpen, onClose }) => {
  const modalRef = useRef(null);

  useEffect(() => {
    if (isOpen) {
      modalRef.current.focus();
    }
  }, [isOpen]);

  return isOpen ? (
    <div ref={modalRef} tabIndex={-1}>
      <h2>Modal Title</h2>
      <button onClick={onClose}>Close</button>
    </div>
  ) : null;
};

Alt Text for Images Images that convey information must have descriptive alt text. For decorative images that do not contribute meaning, use an empty alt attribute (alt="") so screen readers will skip it.


// Informative image with alt text
<img src="profile.jpg" alt="User profile picture" />

// Decorative image with empty alt
<img src="decorative-icon.jpg" alt="" />
Tools and Resources:
  • Lighthouse: Built into Chrome DevTools, Lighthouse audits your page for accessibility and provides recommendations.
  • axe Accessibility Tools: The axe DevTools browser extension checks your app for accessibility violations.
  • WAVE: An online tool to evaluate web accessibility issues.
Benefits of Accessibility:
  • Inclusivity: Ensures that your application is usable by everyone, regardless of their abilities or disabilities.
  • Better Usability: Many accessibility features, such as keyboard navigation and proper focus management, also improve usability for all users.
  • Legal Compliance: In some regions, accessibility is required by law, such as the Americans with Disabilities Act (ADA) in the U.S.

In conclusion, prioritizing accessibility ensures that all users, including those with disabilities, can fully interact with your React application. Implementing semantic HTML, screen reader support, color blindness adjustments, and keyboard navigation can greatly enhance the inclusivity and usability of your app.

Conclusion

...
Under Construction
Under Construction

Conclusion

...