paint-brush
Building Better React & Next.js Apps: Mistakes, Tips, and Best Practicesby@timmy471
253 reads

Building Better React & Next.js Apps: Mistakes, Tips, and Best Practices

by Ayantunji Timilehin13mNovember 12th, 2024
Read on Terminal Reader
Read this story w/o Javascript
tldt arrow

Too Long; Didn't Read

JavaScript developers often encounter three main types of errors: syntax, logical, and runtime. This guide aims to help developers identify and avoid these common pitfalls in React, Next.js, and JavaScript, making code more reliable and maintainable.
featured image - Building Better React & Next.js Apps: Mistakes, Tips, and Best Practices
Ayantunji Timilehin HackerNoon profile picture

As a JavaScript developer, I've come across several recurring errors—both from my own experience, from reviewing code and mentoring others. Some of these mistakes are common to all levels, from beginners to seasoned developers, and understanding them can greatly improve the reliability and efficiency of our code. Through this curated list, I hope to share practical insights that help developers avoid common pitfalls in React, Next.js, and modern JavaScript, ultimately saving time and making code cleaner and more maintainable.


Generally, we can classify these errors into three broad categories - syntax, logical, and runtime errors.

  • Syntax Errors: These are otherwise known as grammar mistakes. They typically happen when the code written does not conform with the language rules (think of it as not speaking the English language correctly). These errors are typically caught by linters or compilers before execution and prevent the code from running.
  • Logical Errors: Logical errors occur when your code runs but doesn’t do as intended, often because the logic is flawed. They’re usually harder to spot because more often than not, the application doesn’t crash.
  • Runtime Errors: As the name implies, runtime errors show up while the program is running, often due to unexpected data or conditions— like attempting to access a property on a null or undefined value. If unhandled, they can cause the application to crash, making error handling and thorough testing very important.


Let’s check out some of these examples:

1.) Object Referencing

It’s essential to understand how references work when working with Javascript objects as it impacts how data is modified accross different part of your code**.** Let’s look at an example:


const original = { name: 'James', bio: { age: 25 } };
const duplicate = original;
duplicate.name = 'Timilehin';

console.log(original.name); // Output: 'Timilehin'


From this, duplicate is supposed to be a new object that can be modified independently of original. However, understanding that duplicate is assigned by reference rather than by value, any change to duplicate directly affects original as well because both original and duplicate point to the same memory location for the object, so changing duplicate.name actually changes the name property in the original object.


To avoid unintentional modifications like this, you need to create a copy of the object. This is where the concepts of shallow and deep copy come in.

Shallow copy

A shallow copy duplicates the object at the top level only, leaving references to nested objects intact. Here’s how it works:


const shallowCopy = { ...original };
shallowCopy.name = 'Timilehin';
shallowCopy.details.age = 30;

console.log(original.name);      // Output: 'Timilehin' - Top-level change doesn't affect original
console.log(original.details.age); // Output: 30 - Nested object is still affected


Using a shallow copy, you can independently modify top-level properties (like name) without impacting the original object. However, if modifying a nested property (like details.age), the original is still affected because nested objects are only copied by reference, not duplicated.

Deep Copy

A deep copy duplicates everything in the object, including nested structures, so the original remains entirely unaffected by changes to the duplicate.


const deepCopy = JSON.parse(JSON.stringify(original));
deepCopy.details.age = 30;

console.log(original.details.age); // Output: 25 - No change to the original object


deepCopy is an entirely independent object in this case, so modifying it has no impact on original. Understanding these differences helps avoid unintended side effects, especially when working with complex state or deeply nested data structures.

2.) Short circuit operator and Zero display

Using arr.length && something is a popular shorthand in JavaScript for checking if an array has elements before doing something with it. However, this shorthand behaves unexpectedly with empty arrays.


Here’s why: the && operator in JavaScript is a "short-circuit" operator, meaning it evaluates the left side (in this case, arr.length) and only moves to the right side if the left side is truthy. When arr.length is 0 (as it is with an empty array), the entire expression immediately stops evaluating and returns 0 because 0 is falsy.


const testArr = []
const TestComponent = () => <p>This is a component to be rendered</p>

const MainComponent = () => {
  return (
    <div>
      <p>This is the main component</p>
      {testArr?.length && <TestComponent />} {/* 0 is displayed on the browser */}
    </div>
  );
};


//Alternatively, use the ternary operator instead
{testArr?.length ? <TestComponent /> : null}


3.) Environment Variables

it’s important to use the correct prefix for environment variables so they’re accessible in the browser. React requires environment variables to be prefixed with REACT_APP_, while Next.js uses NEXT_PUBLIC_. Missing or misnaming these prefixes is a common mistake that causes variables to be undefined in the browser.


I've often seen this oversight happen in code reviews, where forgetting the prefix results in values that don’t get passed correctly to the frontend. To avoid this, always double-check variable names and use the correct prefix for each framework. It’s a small step but can prevent some big headaches later on!

4.) Unnecessary "use client" Statements

Next.js is default is to render components on the server. the "use client" directive allows a component to run on the client side. If overused, or added unneccesarily, it can cause performance issues and affect efficiency of server-side rendering.


  • Use only where necessary.
  • Apply it at the highest level component where client-side behavior is required rather than having it all round nested components.

5.) Linting and Warnings: Why Silencing Isn’t the Solution

Refer to linting and code warnings as “early warning systems”, helping to catch potential issues before they lead to real problems. They identify mistakes, and inconsistencies, ensuring best practices and cleaner code overall. Ignoring or silencing these warnings might seem like a quick fix, but it’s a risky habit that can lead to hidden bugs, performance issues, and harder-to-maintain code.


Instead of silencing;

  • Tackle the warning directly. This improves code quality and helps you develop good coding habits.
  • If a warning suggests a better approach, refactoring code to address it can make it more efficient and readable.
  • If certain rules feel overly strict, configure your linter to prioritize what’s important for your project. This allows you to focus on meaningful warnings rather than silencing everything.

6.) Using className Instead of class

In React and Next Js, you need to use className instead of class when applying CSS classes to elements because class is a reserved word in JavaScript. React uses className as the JSX attribute to assign CSS classes, so if you accidentally use class, it won’t work as expected and may cause errors or warnings in your code


// Correct usage
<div className="container">
  Hello World
</div>

// Incorrect usage
<div class="container">
  Hello World
</div>


7.) Keys in Array rendered with Map

React uses keys to keep track of elements in lists, making the reconciliation process smoother. If keys are missing or not unique, React might re-render list items incorrectly, leading to unexpected behavior. For example, without unique keys, if an item in the middle of a list is updated, React may accidentally update the wrong item or cause the entire list to re-render.


const names = ['James', 'Kola', 'Barry'];

const ListComponent = () => (
  <ul>
    {names.map((name, index) => (
      <li key={index}>{name}</li> // Use a unique identifier here
    ))}
  </ul>
);


  • Avoid using indices as keys if the list can change order, as this can lead to unexpected behavior.

  • Use a unique identifier, like an id, as the key when possible.

  • Always provide keys when rendering lists, even if it’s a static list.


8.) Control Flow Choices: if-else, switch, and Ternary Operators

I've often seen code filled with long if-else chains, overly complex ternary expressions, or switch statements that could be simplified. These choices can easily make code messy and hard to read if we don’t know when each option is best. Choosing between if-else, switch, and ternary operators can simplify the logic and improve readability when used appropriately.


  • If-Else Statements: Best for straightforward, step-by-step checks. If your logic has only a few conditions or requires a nested approach, if-else is likely the best option. If your code starts getting filled with multiple else if blocks, consider using switch to refactor. They’re also the easiest to read in cases where you have one main condition and a few alternatives.


    const calculateDiscount = (userType) => {
      if (userType === 'student') {
        return 0.2; // 20% discount
      } else if (userType === 'teacher') {
        return 0.1; // 10% discount
      } else {
        return 0; // No discount
      }
    }
    

    In this case, if-else is simple and readable. If your conditions become more complex or there are only a few unique conditions, this structure can still work effectively.


  • Switch Statements: switch statements provide clear way to handle multiple possible values for a single variable, especially if those values are fixed (like enums or constants). Switches are clearer and more readable than lengthy if-else chains. Here’s an example:


    // Define an enum for user types
    enum UserType {
      Student = 'student',
      Teacher = 'teacher',
      Senior = 'senior',
    }
    
    // Use the enum in the function
    const calculateDiscount = (userType) => {
      switch (userType) {
        case UserType.Student:
          return 0.2; // 20% discount
        case UserType.Teacher:
          return 0.1; // 10% discount
        case UserType.Senior:
          return 0.15; // 15% discount
        default:
          return 0; // No discount
      }
    }
    
    // Example usage:
    const discount = getDiscount(UserType.Student); // 0.2
    console.log(discount);
    
    


  • Ternary Operators: Ternary operators are best fit for simple, inline conditions and help keep code compact. They work best for straightforward expressions. When ternaries become nested or chained, they quickly become difficult to read and at that point, using a simple if-else statement is usually clearer and more maintainable.


    const discount = userType === 'student' ? 0.2 : 0;
    

9.)Accessibility and User Experience: Going Beyond Functionality

From my experience, a lot of developers often overlook accessibility, unintentionally leaving out users who rely on inclusive design. Adhering to accessibility guidelines is essential, as it ensures that our applications are usable by everyone. Developing with inclusivity in mind isn’t just a best practice, it’s a commitment to providing good experience for all users. Here is a good read on improving accessibility.

10.) Effective State Management and Hook Usage in React

In React, managing state efficiently and understanding hooks can be key to creating a stable and performant app. Here’s a breakdown of important concepts and best practices that prevent common pitfall:

Avoid Direct Mutation in setState

Rather than mutating the state which can lead to unexpected bugs and make the app challenging to debug, use functions that update the state based on the previous value using React’s useState hook.


import React, { useState } from 'react';

const Counter = () => {
  const [counter, setCounter] = useState(0);

  // Incorrect: directly modifying state
  // counter = counter + 1;

  // Correct: using a function to update state
  const incrementCounter = () => {
    setCounter(prevCounter => prevCounter + 1);
  };

  return (
    <div>
      <p>Counter: {counter}</p>
      <button onClick={incrementCounter}>Increment</button>
    </div>
  );
};

export default Counter;

In this example:

  • We use setCounter to update the state instead of modifying counter directly.
  • The setCounter function receives prevCounter as an argument, ensuring the update is based on the latest state value. This pattern helps prevent bugs, especially in cases where state updates might happen asynchronously.

Dependencies in useEffect: Why They’re Crucial

When using useEffect, always include all required dependencies in its dependency array. This ensures that useEffect only re-runs when necessary, such as when a dependency changes, preventing bugs or infinite loops. Omitting dependencies can cause effects to run unexpectedly or miss updates.


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

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

  useEffect(() => {
    console.log(`Count is: ${count}`);
    // Assuming this effect relies on "count" to log correctly.
  }, [count]); // "count" is included here as a dependency.

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


In this example:

  • By including count in the dependency array, React will re-run the effect whenever count changes, ensuring the effect is always in sync with the latest value.
  • Leaving out count would prevent useEffect from re-running when count changes, causing the logged value to be incorrect.


Choosing useState vs. useContext

useState is ideal for managing local state data or a state that only a specific component or a small group of closely related components needs. For example, if a component displays a counter and only that component and its immediate children need access to the counter value, useState is a great choice.


It keeps the state private to the component, which improves performance and keeps your code clean.


import React, { useState } from 'react';

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

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


In my early days, I didn’t fully understand how powerful useContext could be for managing shared data across multiple components, especially in deeply nested trees. I would often pass props manually down through each component, a process known as "prop drilling."


Using useContext removes that complexity by creating a context that acts as a global provider, making the data accessible anywhere within the provider’s component tree. This way, any component in the tree can access or update the shared data without needing to pass it down through props at every level.

Optimizing with useMemo and useCallback

In React, useMemo and useCallback can help optimize performance by caching values and functions that don’t need to be recalculated on every render. Here’s how they work when to use them:


useMemo is helpful when you have a complex computation, like filtering data. It only recomputes the value if its dependencies change, which can save rerenders processing time.


const expensiveCalculation = (input) => {
  console.log('Calculating...');
  return input * 2; // Imagine this is heavy and time consuming calculation
};

// useMemo caches the result of `expensiveCalculation`
const result = useMemo(() => expensiveCalculation(input), [input]);


In this example, expensiveCalculation only runs when input changes. On rerenders with the same input, React skips recalculating and just uses the cached value. This is especially useful in components where you might be passing down data to multiple children or rerendering often.


useCallback is similar but works for functions. If you pass a function as a prop to child components, it’s recreated on every render by default. useCallback tells React to reuse the same function instance as long as its dependencies don’t change, improving performance by reducing unnecessary re-renders.


const handleClick = (input) => {
  console.log('Handling click...');
};

// `useCallback` memoizes `handleClick`
const memoizedHandleClick = useCallback(() => handleClick(input), [input]);


In this case, memoizedHandleClick will retain its reference unless input changes. This way, child components that receive memoizedHandleClick as a prop don’t re-render unnecessarily.

When to use useLayoutEffect hook

In React, useLayoutEffect works similarly to useEffect. The key difference between both is that it runs synchronously after the DOM is updated but before the browser has painted (just before the user sees the changes). This makes useLayoutEffect useful for situations where the code directly interacts with the DOM like measuring elements’ sizes, positions, or making visual adjustments based on these measurements.


A good example is measuring an element’s height immediately after it renders and adjust it if necessary.

import { useLayoutEffect, useRef, useState } from 'react';

const LayoutExample = () => {
  const elementRef = useRef(null);
  const [height, setHeight] = useState(0);

  useLayoutEffect(() => {
    if (elementRef.current) {
      setHeight(elementRef.current.clientHeight);
      
    }
  }, [elementRef]);

  return (
    <div ref={elementRef}>
      <p>Element height: {height}px</p>
    </div>
  );
};


In this example, useLayoutEffect captures the element’s height as soon as it’s added to the DOM and updates the height state.

CONCLUSION

In conclusion, understanding these common mistakes and best practices in React and JavaScript can improve your code quality, performance, and maintainability. Each of the points discussed, from state management and the efficient use of hooks to accessibility contributes to writing cleaner, more efficient applications. Developing with these principles in mind ultimately saves time, reduces bugs, and makes our codebase more enjoyable for both ourselves and our team members.