Not all falsy values are equal in JSX

One of the most common mistakes in React code shows up right inside the JSX itself. Consider a common conditional pattern:

import React from 'react';
import ShoppingList from './ShoppingList';

function App() {
  const [items, setItems] = React.useState([]);
  
  return (
    <div>
      {items.length && <ShoppingList items={items} />}
    </div>
  );
}

export default App;

The intent is to render a ShoppingList only when there's at least one item. But users end up seeing a stray 0 on the page. That happens because items.length resolves to 0, which is falsy—so the && short-circuits—but the returned value is still the number 0 itself, not a boolean. And unlike other falsy values, JSX deliberately renders the number zero as visible text.

The fix is to make the conditional a pure boolean:

function App() {
  const [items, setItems] = React.useState([]);

return (
    <div>
      {items.length > 0 && (
        <ShoppingList items={items} />
      )}
    </div>
  );
}

Alternatively, a ternary accomplishes the same thing. Either approach prevents accidental numeric output in your UI.

Never mutate state directly

A related problem occurs when developers try to update state by modifying the existing value. Consider an array held in state:

import React from 'react';
import ShoppingList from './ShoppingList';
import NewItemForm from './NewItemForm';

function App() {
  const [items, setItems] = React.useState([
    'apple',
    'banana',
  ]);
  
  function handleAddItem(value) {
    items.push(value);
    setItems(items);
  }
  
  return (
    <div>
      {items.length > 0 && <ShoppingList items={items} />}
      <NewItemForm handleAddItem={handleAddItem} />
    </div>
  )
}

export default App;

This handler looks correct but silently fails. The culprit is a direct mutation:

function handleAddItem(value) {
  items.push(value);
  setItems(items);
}

React detects changes by comparing state values by identity. push() modifies the array in place, so the original identity never changes—and no re-render happens. The solution is to pass a new array to the setter with the spread operator:

function handleAddItem(value) {
  const nextItems = [...items, value];
  setItems(nextItems);
}

The same rule applies to objects in state; create a brand new entity with spreads or similar techniques, never edit the existing one.

Crafting robust list keys

React requires a unique key prop for each element in a rendered list. The easy fix most developers reach for is the index:

function ShoppingList({ items }) {
  return (
    <ul>
      {items.map((item, index) => {
        return (
          <li key={index}>{item}</li>
        );
      })}
    </ul>
  );
}

That's fragile. When data is inserted, removed, or reordered, indexes become misleading and React's reconciliation process misbehaves. A better approach is to give each item a permanent unique ID when the data is first created—whether from a spread of new form input or a fetched data payload:

const [data, setData] = React.useState(null);

async function retrieveData() {
  const res = await fetch('/api/data');
  const json = await res.json();

  // The moment we have the data, we generate
  // an ID for each item:
  const dataWithId = json.data.map(item => {
    return {
      ...item,
      id: crypto.randomUUID(),
    };
  });

  // Then we update the state with
  // this augmented data:
  setData(dataWithId);
}

Use the crypto.randomUUID() API to produce that ID. Generating the key inside the JSX itself is a trap: it re-creates on every render, causing unnecessary DOM churn and performance problems.

The hidden whitespace problem

JSX collapses what looks like deliberate spacing. When mixing text and inline elements on separate lines, the output can be missing spaces in a way that's rarely obvious on first read:

import React from 'react';

function App() {
  return (
    <p>
      Welcome to Corpitech.com!
      <a href="/login">Log in to continue</a>
    </p>
  );
}

export default App;

The text runs together because the JSX compiler can't distinguish structural indentation from intentional content spaces. Insert an explicit space character as a string interaction:

<p>
  Welcome to Corpitech.com!
  {' '}
  <a href="/login">Log in to continue</a>
</p>

Avoid manually splitting text nodes into surprising patterns—formatting tools like Prettier will add these natural spaces for you automatically.

State is scheduled, not assigned

It is surprisingly easy to read state immediately after calling a setter:

import React from 'react';

function App() {
  const [count, setCount] = React.useState(0);
  
  function handleClick() {
    setCount(count + 1);
    
    console.log({ count });
  }
  
  return (
    <button onClick={handleClick}>
      {count}
    </button>
  );
}

export default App;
function handleClick() {
  setCount(count + 1);
  console.log({ count });
}

State updaters are asynchronous. When you call setCount, you are enqueueing a render, not reassigning a local variable. To inspect what the next value will be, capture it ahead of time:

function handleClick() {
  const nextCount = count + 1;
  setCount(nextCount);

  // Use `nextCount` whenever we want
  // to reference the new value:
  console.log({ nextCount });
}

Naming the captured value with a “next” prefix makes this mental model explicit, which helps avoid confusion inside early React learning curves.

Multiple elements need a wrapper or fragment

JSX looks like HTML, but compiles down to JavaScript calls. Trying to return two adjacent elements from a component ends poorly:

function LabeledInput({ id, label, ...delegated }) {
  return (
    <label htmlFor={id}>
      {label}
    </label>
    <input
      id={id}
      {...delegated}
    />

In JavaScript, return only accepts one expression. Wrapping siblings in a container element introduces otherwise unnecessary DOM nodes. The cleaner approach is fragments:

function LabeledInput({ id, label, ...delegated }) {
  return (
    <>
      <label htmlFor={id}>
        {label}
      </label>
      <input
        id={id}
        {...delegated}
      />
    </>
  );
}

Fragments bundle multiple top-level elements in JSX without creating any real HTML output.

Controlled inputs need defined values

When an input's value prop is provided, React will treat it as a controlled component—but the state behind it needs a concrete initial value. If the state begins as undefined, React complains about an uncontrolled-to-controlled switch:

import React from 'react';

function App() {
  const [email, setEmail] = React.useState();
  
  return (
    <form>
      <label htmlFor="email-input">
        Email address
      </label>
      <input
        id="email-input"
        type="email"
        value={email}
        onChange={event => setEmail(event.target.value)}
      />
    </form>
  );
}

export default App;
const [email, setEmail] = React.useState('');

An empty string removes the ambiguity between one render cycle and the next.

Styles are objects, not strings

In HTML, style attributes are strings. In JSX, they're objects—and that extra layer of syntax twists many first-timers:

import React from 'react';

function App() {
  return (
    <button
      style={ color: 'red', fontSize: '1.25rem' }
    >
      Hello World
    </button>
  );
}

export default App;

Any style attribute needs an outer set of JSX curly braces containing a JavaScript object literal with properties in camelCase.

// 1. Create the style object:
const btnStyles = { color: 'red', fontSize: '1.25rem' };

// 2. Pass that object to the `style` attribute:
<button style={btnStyles}>
  Hello World
</button>

// Or, we can do it all in 1 step:
<button style={{ color: 'red', fontSize: '1.25rem' }}>

So the pattern ends up with “double squigglies”: the outer set opens the expression slot; the inner set is genuine JavaScript object syntax inside that slot.

Async Logic in Effects: A Subtle Gotcha

Fetching data on mount is a common task, and it's natural to reach for await inside a useEffect. Consider this initial attempt:

import React from 'react';
import { API } from './constants';

function UserProfile({ userId }) {
  const [user, setUser] = React.useState(null);
  
  React.useEffect(() => {
    const url = `${API}/get-profile?id=${userId}`;
    const res = await fetch(url);
    const json = await res.json();
    
    setUser(json.user);
  }, [userId]);
  
  if (!user) {
    return 'Loading…';
  }
  
  return (
    <section>
      <dl>
        <dt>Name</dt>
        <dd>{user.name}</dd>
        <dt>Email</dt>
        <dd>{user.email}</dd>
      </dl>
    </section>
  );
}

export default UserProfile;

This immediately fails with a familiar error: 'await' is only allowed within async functions. The fix seems obvious—make the effect callback async:

React.useEffect(async () => {
  const url = `${API}/get-profile?id=${userId}`;
  const res = await fetch(url);
  const json = await res.json();

  setUser(json);
}, [userId]);

Surprisingly, this throws a different, more cryptic error: destroy is not a function. Why is this happening?

The problem lies in what the async keyword actually does. Take this simple function:

async function greeting() {
  return "Hello world!";
}

The intuitive answer is that it returns the string "Hello world!". In reality, an async function always returns a promise that resolves to that value. The useEffect hook, however, doesn't expect a promise. It expects a return value of either undefined or a cleanup function.

Because an async callback returns a promise, React mistakes that promise for a cleanup function. When React tries to call it during the cleanup phase (or before the next effect run), it fails because a promise isn't callable, leading to the destroy is not a function error.

The solution is to define a separate async function *inside* the effect, and then call it. This keeps the effect callback synchronous and allows it to return a proper cleanup function when needed:

React.useEffect(() => {
  // Create an async function...
  async function runEffect() {
    const url = `${API}/get-profile?id=${userId}`;
    const res = await fetch(url);
    const json = await res.json();

    setUser(json);
  }

  // ...and then invoke it:
  runEffect();
}, [userId]);

This pattern is effective because we can now return a cleanup function right away, as React expects:

React.useEffect(() => {
  async function runEffect() {
    // Effect logic here
  }
  runEffect();

  return () => {
    // Cleanup logic here
  }
}, [userId]);

You can name this inner function whatever you prefer; a common and descriptive name is runEffect, as it contains the primary effect logic.

Building an Intuition for React

Many of the patterns discussed—from unique keys to state immutability to this async workaround—can seem arbitrary at first. It's easy to feel like you're memorizing a list of "gotchas" rather than understanding a coherent system.

This is a normal stage of learning React. It takes time for the mental model to solidify. At first, the rules feel external and confusing: Why this specific key? Why can't I read state right after updating it? Why is useEffect so particular about what it returns?

The key to fluency is developing an intuition for the underlying mechanics. Once you understand *why* React behaves in these ways, you no longer have to rely on memorization. With a more accurate mental model, the rules become logical consequences of the system's design. Solving problems becomes much more straightforward—and more enjoyable—when you can reason from first principles rather than recite a list of exceptions.