Back to blog
web development

Some of the little tips & best practices for React/Next.js developers

·10 min read
Piotr
Piotr
Founder
main-tips-tricks

Introduction

I am sharing here some of the simple yet productive practices for anyone working with React/Next.js. This is not an exhaustive list, but it covers some of the most common questions I have encountered across various projects and forums.

1 - Avoid Hardcoded Values

Defining all hardcoded values in one place enhances the maintainability and scalability of your application. It makes updating these values easier later on, especially when they are used in multiple locations. You can create a separate file (e.g., constants.js or config.js) to store all your constants and import them wherever needed.

Example:

Create a constants.js file:

javascript
1// constants.js
2export const MAX_TODOS = 3

Use it in your component:

javascript
1// TodoComponent.js
2import { MAX_TODOS } from './constants'
3
4if (todos.length === MAX_TODOS && !isAuthenticated) {
5 alert(`You need to sign in to add more than ${MAX_TODOS} todos.`)
6 return
7}

2 - Best Practice for Folder Structure

Don't stress too much about finding the perfect folder structure. While there's no one-size-fits-all solution, the most important thing is to keep your folder organization consistent throughout the project. Consistency aids maintainability and helps team members navigate the codebase more efficiently.

Why Consistency Matters:

  • Ease of Navigation: A consistent structure allows developers to find files quickly without confusion.
  • Team Collaboration: It ensures that everyone on the team is on the same page, reducing misunderstandings.
  • Scalability: As your project grows, a consistent structure makes it easier to manage and extend.


Tips for Maintaining Consistency:

  • Choose a structure early: Decide on a folder organization strategy at the beginning of the project.
  • Document the structure: Create a document outlining the folder structure and share it with the team.
  • Regularly review and update: Periodically review the structure to ensure it meets the project's needs.
  • Use Naming Convention: Follow a consistent naming convention for folders and files.
  • Leverage Tools: Use tools like ESLint and Prettier to enforce consistency.


Final Thoughts:

There's no definitive "best" folder structure in React or Next.js projects. What matters most is that the structure you choose makes sense for your project's specific requirements and that everyone working on the project adheres to it. This collective consistency will contribute significantly to a smooth development process and a maintainable codebase.

3 - When to Create Components?

A good rule of thumb is to create a new component whenever you notice a piece of code repeated more than once. This approach enhances the readability and maintainability of your code.

Why Create New Components?

  • Reusability: Components can be reused across different parts of your application.
  • Modularity: Breaking down your UI into components makes it easier to manage and update.
  • Readability: Smaller components are easier to understand and debug.

When?

1. Repeated Code: If you find yourself copying and pasting the same code in multiple places, it's time to create a component.

javascript
1/ Before: Repeating code
2<button className="btn btn-primary" onClick={handleSubmit}>
3 Submit
4</button>
5// ...
6<button className="btn btn-primary" onClick={handleSave}>
7 Save
8</button>
javascript
1// After: Using a reusable component
2<PrimaryButton onClick={handleSubmit}>Submit</PrimaryButton>
3// ...
4<PrimaryButton onClick={handleSave}>Save</PrimaryButton>
javascript
1// PrimaryButton.js
2export function PrimaryButton({ onClick, children }) {
3 return (
4 <button className="btn btn-primary" onClick={onClick}>
5 {children}
6 </button>
7 )
8}

2. Complex Logic or Rendering:

When a piece of your UI involves complex logic or rendering, extracting it into a component can simplify your main component.

javascript
1// After: Extracted into a separate component
2function Dashboard() {
3 return (
4 <div>
5 {/* ...other components... */}
6 <Chart data={data} />
7 {/* ...other components... */}
8 </div>
9 )
10}
11
12// Chart.js
13export function Chart({ data }) {
14 // Complex chart rendering logic
15 return <div className="chart">{/* Render chart with data */}</div>
16}

3. Different Variations of a Component:

javascript
1// Badge.js
2export function Badge({ type, text }) {
3 return <span className={`badge badge-${type}`}>{text}</span>;
4}
5
6// Usage
7<Badge type="success" text="Active" />
8<Badge type="warning" text="Pending" />

BEST PRACTICES:

  • Single Responsibility Principle: Each component should have a single responsibility, making it easier to maintain and reuse.
  • Prop Validation:
    • Use PropTypes or TypeScript to validate props passed to components.
    • This helps catch errors early and ensures that components receive the correct data.
  • Avoid Premature Optimization: Don't create components for the sake of it. If a piece of code is only used once and isn't overly complex, it might not need to be a separate component.

Naming Conventions: Use clear and descriptive names for your components to make their purpose obvious.

4 - Avoid Unnecessary Markup (divs, spans, etc.)

Keeping your markup as clean and minimal as possible is crucial for creating efficient and maintainable React and Next.js applications. Avoid adding unnecessary div, span, or other elements that do not contribute meaningfully to your application's structure or functionality.

javascript
1export default function Btn() {
2 return (
3 <div>
4 <button>Click me</button> // unnecessary div
5 </div>
6 )
7}

Why This Matters:

  • Improved Performance: Fewer DOM nodes lead to better rendering performance, which is especially in large or complex applications.
  • Cleaner Codebase: Minimal and meaningful markup makes your code easier to read, understand, and maintain.
  • Enhanced Accessibility: Simplifying your DOM structure can improve accessibility for users relying on assistive technologies.

Simplified Styling: Reducing unnecessary nesting can make CSS selectors simpler and more efficient.

5 - Don't Add Layout Styles Directly to Your Reusable Components

When building reusable components in React or Next.js, it is important to keep them flexible and adaptable to different contexts. Avoid hardcoding layout-specific styles (like margins, paddings, flex properties) directly into your reusable components. Instead, allow these components to accept styles or class names as props so that you can control their layout from the parent components where they are used.


Why This Matters:

  • Flexibility: Components can be used in various layouts without being tied to a specific design.
  • Reusability: Components can adapt to different styling requirements based on where they are used.
  • Separation of Concerns: Keeps layout and styling concerns separate from the component logic.


javascript
1export default async function EventsPage({ params }: EventsPageProps) {
2 return (
3 <div className="flex min-h-[110vh] flex-col items-center px-[20px] py-24">
4 <h1>Events</h1>
5 <EventList events={events} />
6 </div>
7 )
8}


I think that a better solution would be to pass className as a prop to the EventList component so we can dynamically add styles to it.

javascript
1import { cn } from '@/lib/cn'
2
3type H1Props = {
4 children: React.ReactNode
5 className?: string
6}
7
8export default function H1({ children, className }: H1Props) {
9 return <h1 className={cn('text-2xl font-bold', className)}>{children}</h1>
10}

6 - Keep Components "Dumb" - As Simple as Possible

In React and Next.js development, it's advantageous to keep your components as simple and focused as possible. "Dumb" components, also known as presentational components, should primarily handle rendering UI elements and accepting props. They should not contain complex logic or side effects.

In this example, the StatusBar component calculates the progressPercentage internally, mixing business logic with UI rendering. By moving the calculation of progressPercentage out of the StatusBar component, we can keep it focused solely on rendering the UI.

javascript
1export default function StatusBar({ progressPercentage }: StatusBarProps) {
2 return (
3 <div className="h-4 w-full bg-gray-200">
4 <div
5 className="h-full bg-green-500"
6 style={{ width: `${progressPercentage}%` }}
7 />
8 </div>
9 )
10}

Some of the best practices:

1. Use Container and Presentational Components:

  • Container Components: Handle logic, data fetching, and state management.
  • Presentational Components: Focus on rendering UI elements and accepting props.

javascript
1// ContainerComponent.tsx
2
3import StatusBar from './StatusBar'
4
5export default function ContainerComponent() {
6 const [currentProgress, setCurrentProgress] = useState(30)
7 const total = 100
8 const progressPercentage = (currentProgress / total) * 100
9
10 return <StatusBar progressPercentage={progressPercentage} />
11}

2. Leverage Custom Hooks for Logic:

  • Extract reusable logic into custom hooks to keep components clean and focused.


javascript
1// useProgress.ts
2export function useProgress(currentProgress: number, total: number) {
3 return (currentProgress / total) * 100
4}
5
6// ParentComponent.tsx
7import { useProgress } from './useProgress'
8import StatusBar from './StatusBar'
9
10export default function ParentComponent({
11 currentProgress,
12 total,
13}: ParentProps) {
14 const progressPercentage = useProgress(currentProgress, total)
15
16 return <StatusBar progressPercentage={progressPercentage} />
17}

3. Avoid State in Presentational Components:

  • Keep presentational components stateless and pass data as props from container components.

7 - Use children to avoid props drilling

Prop drilling occurs when you pass data through multiple layers of components to reach a deeply nested component. This can make your code less readable and harder to maintain. One way to mitigate prop drilling is by leveraging the children prop in React. The children prop allows you to pass components directly to other components, enabling you to compose your UI more flexibly and avoid passing props through components that don't need them.

By using the children prop, you can pass components directly to the ParentComponent without having to pass them through the ChildComponent.

javascript
1// App.js
2function App() {
3 const user = { name: 'John Doe' }
4 return (
5 <Parent>
6 <Grandchild user={user} />
7 </Parent>
8 )
9}
10
11// Parent.js
12function Parent({ children }) {
13 return <div>{children}</div>
14}
15
16// Grandchild.js
17function Grandchild({ user }) {
18 return <div>Hello, {user.name}!</div>
19}

Now, the user prop is passed directly to Grandchild and Parent doesn't need to pass it down explicitly.

8 - Consider Using useMemo and useCallback for Performance Optimization

When building React applications, performance optimization becomes crucial as your app scales. useMemo and useCallback are two hooks that can help optimize your components by memoizing values and functions, respectively.

Understanding useMemo and useCallback

  • useMemo: Memoizes the result of a function and returns the cached value when the dependencies don't change.
  • useCallback: Returns a memoized version of a callback function that only changes if one of the dependencies has changed. It's beneficial when passing callbacks to optimized child components that rely on reference equality to prevent unnecessary renders.


When to Use useMemo

Use useMemo when you have expensive computations that don't need to run on every render unless their dependencies change.

Example:

javascript
1import React, { useMemo } from 'react'
2
3function ExpensiveComponent({ items }) {
4 const total = useMemo(() => {
5 // Assume calculateTotal is a computationally expensive function
6 return calculateTotal(items)
7 }, [items])
8
9 return <div>Total: {total}</div>
10}
11
12function calculateTotal(items) {
13 // Simulate an expensive calculation
14 return items.reduce((sum, item) => sum + item.value, 0)
15}

In this example, calculateTotal(items) is only recalculated when the items prop changes, saving unnecessary computations on re-renders.

When to Use useCallback

Use useCallback when you need to prevent a function from being recreated on every render, especially when passing it to child components that are optimized with React.memo.

Example:

javascript
1import React, { useState, useCallback } from 'react'
2import List from './List'
3
4function ParentComponent() {
5 const [items, setItems] = useState([])
6
7 const addItem = useCallback(() => {
8 setItems((prevItems) => [...prevItems, 'New Item'])
9 }, [])
10
11 return (
12 <div>
13 <button onClick={addItem}>Add Item</button>
14 <List items={items} onAddItem={addItem} />
15 </div>
16 )
17}
18
19// List.js
20import React from 'react'
21
22const List = React.memo(({ items, onAddItem }) => {
23 console.log('Rendering List')
24 return (
25 <ul>
26 {items.map((item, index) => (
27 <li key={index}>{item}</li>
28 ))}
29 <button onClick={onAddItem}>Add Item from List</button>
30 </ul>
31 )
32})
33
34export default List

Without useCallback, the addItem would be recreated on every render, causing List to re-render unnecessarily even if items didn't change. By memoizing addItem with useCallback, List only re-renders when necessary.

9 - Keep Track of Active or Selected Item by its ID, Don't Duplicate the Whole Object

When managing state in React components, it's important to avoid duplicating entire objects in your state to keep track of active or selected items. Instead, store the unique identifier (ID) ot the item. This practice reduces errors, prevents data inconsistencies, and simplifies state management.

Incorrect Approach - Duplicating the whole object in state:

javascript
1function ItemList({ items }) {
2 const [selectedItem, setSelectedItem] = useState(null)
3
4 const handleSelect = (item) => {
5 setSelectedItem(item) // Storing the entire item object
6 }
7
8 return (
9 <ul>
10 {items.map((item) => (
11 <li
12 key={item.id}
13 onClick={() => handleSelect(item)}
14 className={
15 selectedItem && selectedItem.id === item.id ? 'active' : ''
16 }
17 >
18 {item.name}
19 </li>
20 ))}
21 </ul>
22 )
23}

Correct Approach - Storing the ID of the Selected Item:

javascript
1function ItemList({ items }) {
2 const [selectedItemId, setSelectedItemId] = useState(null)
3
4 const handleSelect = (id) => {
5 setSelectedItemId(id) // Storing only the item's ID
6 }
7
8 const selectedItem = useMemo(
9 () => items.find((item) => item.id === selectedItemId),
10 [items, selectedItemId],
11 )
12
13 return (
14 <div>
15 <ul>
16 {items.map((item) => (
17 <li
18 key={item.id}
19 onClick={() => handleSelect(item.id)}
20 className={selectedItemId === item.id ? 'active' : ''}
21 >
22 {item.name}
23 </li>
24 ))}
25 </ul>
26 {selectedItem && (
27 <div className="item-details">
28 <h2>{selectedItem.name}</h2>
29 <p>{selectedItem.description}</p>
30 </div>
31 )}
32 </div>
33 )
34}

Benefits of this approach:

  • Data Consistency: The selectedItem is always in sync with the latest data in items.
  • Simplified Comparisons: Comparing IDs is more efficient than comparing entire objects.
  • Reduced Memory Footprint: Only the necessary data (the ID) is stored in state.

10 - Put Data Like Filters, Variants, and Pagination in the URL, Not in useState

When building web applications with React and Next.js it is important to manage state in a way that enhances user experience and application functionality. Storing data such as filters, variants, and pagination in the URL query parameters rather than in local component state useState offers several benefits, including better usability, shareability, and browser navigation support.

Why Store State in the URL?

  • Shareability: Users can share the URL with others, preserving the exact state of the page, including applied filters and pagination.
  • Bookmarking: Users can bookmark the page and return to the same state later.
  • Browser Navigation: The back and forward buttons work as expected, allowing users to navigate through their history of applied filters or paginated pages.
  • SEO Benefits: For public pages, search engines can index different states of your page if filters are included in the URL.

**Server-Side Rendering (SSR): **Next.js can fetch data based on URL parameters during SSR, improving performance and SEO.

Example Scenario:

Consider a product listing page where users can apply filters (e.g., category, price range), select variants, or navigate through pages.

Incorrect Approach - Using useState to manage filters and pagination:

javascript
1// ProductsPage.js
2import { useState, useEffect } from 'react'
3
4function ProductsPage() {
5 const [filters, setFilters] = useState({
6 category: 'all',
7 priceRange: [0, 100],
8 })
9 const [products, setProducts] = useState([])
10
11 useEffect(() => {
12 // Fetch products based on filters
13 fetch(
14 `/api/products?category=${filters.category}&minPrice=${filters.priceRange[0]}&maxPrice=${filters.priceRange[1]}`,
15 )
16 .then((res) => res.json())
17 .then((data) => setProducts(data))
18 }, [filters])
19
20 // Handler to update filters
21 const handleFilterChange = (newFilters) => {
22 setFilters(newFilters)
23 }
24
25 return (
26 <div>
27 <Filters onChange={handleFilterChange} />
28 <ProductList products={products} />
29 </div>
30 )
31}

Issues with this approach:

  • The current filters are not reflected in the URL.
  • Users cannot share or bookmark the page with the applied filters.
  • Browser back and forward buttons do not navigate through filter changes.

Correct Approach - Storing filters in the URL:

javascript
1// ProductsPage.js
2import { useRouter } from 'next/router'
3import { useEffect, useState } from 'react'
4
5function ProductsPage() {
6 const router = useRouter()
7 const { query } = router
8 const [products, setProducts] = useState([])
9
10 useEffect(() => {
11 // Extract filters from the query parameters
12 const { category = 'all', minPrice = '0', maxPrice = '100' } = query
13
14 // Fetch products based on filters
15 fetch(
16 `/api/products?category=${category}&minPrice=${minPrice}&maxPrice=${maxPrice}`,
17 )
18 .then((res) => res.json())
19 .then((data) => setProducts(data))
20 }, [query])
21
22 // Handler to update filters
23 const handleFilterChange = (newFilters) => {
24 router.push({
25 pathname: '/products',
26 query: { ...query, ...newFilters },
27 })
28 }
29
30 return (
31 <div>
32 <Filters currentFilters={query} onChange={handleFilterChange} />
33 <ProductList products={products} />
34 </div>
35 )
36}

Benefits of this approach:

  • Fetching Filters from the URL:
    • The useRouter hook provides access to the URL query parameters, allowing you to fetch filters directly from the URL.
    • Default values are provided in case the query parameters are not present.
  • Updating Filters in the URL:
    • When filters change, the handleFilterChange function updates the URL query parameters using router.push.
    • The page automatically re-renders based on the updated query parameters.
  • Fetching Data:
    • The useEffect hook listens to changes in the query object and fetches new data accordingly.

Advantages Over useState:

  • Persistence: State stored in useState is lost on page refresh or direct URL access, whereas URL parameters persist.
  • User Control: Users can manually adjust the URL parameters if needed.
  • Debugging: It's easier to debug and replicate issues when the application's state is reflected in the URL.

Potential Considerations:

  • URL Length Limits: Be cautious of exceeding URL length limits, especially when storing large amounts of data.
  • Privacy Concerns: Avoid storing sensitive information in the URL, as it can be logged or shared unintentionally.

Happy coding!

Piotr

About the author

Piotr
Piotr
Founder

Results-driven and forward-thinking Senior Leader in Payments, Banking & Finance with expertise in AI, Full Stack Development, and Python programming.