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.