React best practices for single-page web applications

In this post, we breakdown the best practices for developing single-page applications with React - from file structure to performance to testing.
  • Jul 13, 2023
  • 1 min read

React best practices for single-page web applications

In this post, we breakdown the best practices for developing single-page applications with React - from file structure to performance to testing.

Why React js

React is an excellent choice for building single-page applications. Developed by Facebook and now widely adopted, React is our preferred technology choice for building single-page applications due to its excellent performance and clean component-based architecture.

React structures the user interface into a set of small individual components, each having their own isolated state. The structure makes it very easy to develop reusable components and test them.

One of the most significant advantages of developing applications using React is performance. Rather than manipulate the elements of the page directly, React works with an in-memory representation of the page (the "Virtual DOM") before copying the changes to the actual HTML tree, delivering much faster user experience.

Project Structure Best Practices

Organize by Features, Not by File Types

Instead of organizing files by type (components, containers, reducers), organize them by feature. This makes the codebase more maintainable as your application grows.

src/
  components/         # Shared components
    Button/
      Button.jsx
      Button.test.js
      Button.module.css
  features/
    authentication/
      components/
      hooks/
      services/
    dashboard/
      components/
      hooks/
      services/
  utils/
  hooks/

Use Index Files for Cleaner Imports

Create index.js files in directories to provide cleaner import statements:

// components/index.js
export { Button } from './Button/Button';
export { Modal } from './Modal/Modal';

// In your component files
import { Button, Modal } from '../components';

Component Best Practices

Use Functional Components and Hooks

Functional components with hooks are the modern React way. They're simpler to test and reason about:

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

const UserProfile = ({ userId }) => {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetchUser(userId)
      .then(setUser)
      .finally(() => setLoading(false));
  }, [userId]);

  if (loading) return <div>Loading...</div>;
  if (!user) return <div>User not found</div>;

  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
    </div>
  );
};

Keep Components Small and Focused

Follow the Single Responsibility Principle. Each component should have one reason to change:

// Good: Focused components
const UserAvatar = ({ user }) => (
  <img src={user.avatar} alt={user.name} />
);

const UserInfo = ({ user }) => (
  <div>
    <h2>{user.name}</h2>
    <p>{user.title}</p>
  </div>
);

const UserCard = ({ user }) => (
  <div className="user-card">
    <UserAvatar user={user} />
    <UserInfo user={user} />
  </div>
);

Use PropTypes or TypeScript

Always define your component's interface:

import PropTypes from 'prop-types';

const Button = ({ onClick, children, variant = 'primary' }) => {
  return (
    <button 
      onClick={onClick}
      className={`btn btn--${variant}`}
    >
      {children}
    </button>
  );
};

Button.propTypes = {
  onClick: PropTypes.func.isRequired,
  children: PropTypes.node.isRequired,
  variant: PropTypes.oneOf(['primary', 'secondary'])
};

Performance Optimization

Use React.memo for Expensive Components

Prevent unnecessary re-renders of components that receive the same props:

const ExpensiveComponent = React.memo(({ data }) => {
  // Expensive computation
  const processedData = processExpensiveData(data);
  
  return <div>{processedData}</div>;
});

Optimize State Updates

Avoid creating new objects in render methods:

// Bad
const MyComponent = () => {
  return (
    <ChildComponent 
      style={{ marginTop: 10 }} // New object every render
    />
  );
};

// Good
const styles = { marginTop: 10 };

const MyComponent = () => {
  return (
    <ChildComponent style={styles} />
  );
};

Use useMemo and useCallback Wisely

Don't overuse these hooks, but use them for expensive calculations and stable function references:

const SearchResults = ({ query, results }) => {
  const filteredResults = useMemo(() => {
    return results.filter(item => 
      item.name.toLowerCase().includes(query.toLowerCase())
    );
  }, [query, results]);

  const handleResultClick = useCallback((resultId) => {
    trackEvent('result_clicked', { resultId });
  }, []);

  return (
    <div>
      {filteredResults.map(result => (
        <ResultItem 
          key={result.id}
          result={result}
          onClick={handleResultClick}
        />
      ))}
    </div>
  );
};

State Management Best Practices

Start with Local State

Don't jump to global state management immediately. Use local component state first:

const SearchInput = ({ onSearch }) => {
  const [query, setQuery] = useState('');
  const [isSearching, setIsSearching] = useState(false);

  const handleSubmit = async (e) => {
    e.preventDefault();
    setIsSearching(true);
    try {
      await onSearch(query);
    } finally {
      setIsSearching(false);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input 
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        disabled={isSearching}
      />
      <button disabled={isSearching}>
        {isSearching ? 'Searching...' : 'Search'}
      </button>
    </form>
  );
};

Use Context Sparingly

Context is great for truly global state, but can cause performance issues if overused:

// Good: Theme context
const ThemeContext = createContext();

const ThemeProvider = ({ children }) => {
  const [theme, setTheme] = useState('light');
  
  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      {children}
    </ThemeContext.Provider>
  );
};

Testing Best Practices

Test Behavior, Not Implementation

Focus on what your components do, not how they do it:

import { render, screen, fireEvent } from '@testing-library/react';
import LoginForm from './LoginForm';

test('shows error message on invalid credentials', async () => {
  render(<LoginForm />);
  
  fireEvent.change(screen.getByLabelText(/email/i), {
    target: { value: '[email protected]' }
  });
  
  fireEvent.change(screen.getByLabelText(/password/i), {
    target: { value: 'wrongpassword' }
  });
  
  fireEvent.click(screen.getByRole('button', { name: /login/i }));
  
  expect(await screen.findByText(/invalid credentials/i)).toBeInTheDocument();
});

Use Testing Library Best Practices

Query elements the way users would:

// Good
screen.getByRole('button', { name: /submit/i });
screen.getByLabelText(/email address/i);
screen.getByText(/welcome back/i);

// Avoid
screen.getByTestId('submit-button');
screen.getByClassName('email-input');

Code Quality and Consistency

Use ESLint and Prettier

Set up automated code formatting and linting:

// .eslintrc.json
{
  "extends": [
    "react-app",
    "react-app/jest"
  ],
  "rules": {
    "react/prop-types": "error",
    "react/no-unused-prop-types": "error",
    "react/jsx-uses-react": "off",
    "react/react-in-jsx-scope": "off"
  }
}

Use Absolute Imports

Configure absolute imports to avoid relative path hell:

// jsconfig.json or tsconfig.json
{
  "compilerOptions": {
    "baseUrl": "src"
  }
}

// Instead of
import Button from '../../../components/Button';

// Use
import Button from 'components/Button';

Error Handling

Use Error Boundaries

Catch JavaScript errors anywhere in the component tree:

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error) {
    return { hasError: true };
  }

  componentDidCatch(error, errorInfo) {
    console.log('Error caught by boundary:', error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      return <h1>Something went wrong.</h1>;
    }

    return this.props.children;
  }
}

Build and Deployment

Optimize Bundle Size

Use webpack-bundle-analyzer to identify large dependencies:

npm install --save-dev webpack-bundle-analyzer
npx webpack-bundle-analyzer build/static/js/*.js

Use Code Splitting

Split your code at the route level:

import { lazy, Suspense } from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';

const Home = lazy(() => import('./pages/Home'));
const Dashboard = lazy(() => import('./pages/Dashboard'));

function App() {
  return (
    <Router>
      <Suspense fallback={<div>Loading...</div>}>
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/dashboard" element={<Dashboard />} />
        </Routes>
      </Suspense>
    </Router>
  );
}

Conclusion

Following these React best practices will help you build maintainable, performant, and scalable single-page applications. Remember that best practices evolve with the React ecosystem, so stay updated with the latest recommendations from the React team and the community.

The key is to start simple, focus on user experience, and gradually optimize as your application grows. Don't try to implement every optimization from day one – build a solid foundation first, then enhance based on actual performance metrics and user feedback.