Conditional and Repeated UI

Render one thing or another with a ternary or &&, render an array with map, and give every item a stable key.

Syntax{items.length > 0 ? items.map((i) => <Row key={i.id} />) : <Empty />}

Screens change shape. A list is sometimes empty, a badge only sometimes applies. JSX has no if tag because it does not need one: you are inside JavaScript, so you use expressions.

One thing or another

cond ? <A /> : <B /> picks a branch. cond && <A /> renders A or nothing, because React skips children that are false, null or undefined. When a whole screen differs, an early return above the main JSX reads better than a ternary wrapped around everything.

An array of elements

React accepts an array as children, so {items.map((item) => <Row key={item.id} />)} renders a row per item. Use map, not forEach: you need the returned array.

Why key exists

Between renders React matches the old children to the new ones. key is how it identifies an item across renders, so inserting at the front moves existing elements instead of rewriting them, and any state belonging to an item stays with it. Keys must be unique among siblings and must not change between renders. The array index is what React falls back to when you omit a key, and it is wrong as soon as the list can reorder, gain or lose items. Never Math.random(). Use an id from the data.

What trips people up

&& returns its left operand when that operand is falsy, so {items.length && <Row />} renders the number 0, and a bare 0 outside a <Text> crashes the screen. Compare instead: items.length > 0 &&. Also note map renders every item at once, which is fine for a short fixed list; long or unbounded lists need FlatList, covered in the lists stage.

Example

Example · tsx
// App.tsx
import { StyleSheet, Text, View } from "react-native";

type Task = { id: string; title: string; done: boolean };

const tasks: Task[] = [
  { id: "t1", title: "Pack charger", done: true },
  { id: "t2", title: "Book taxi", done: false },
];

export default function App() {
  return (
    <View style={styles.screen}>
      {/* Ternary: one branch or the other, each a piece of JSX. */}
      {tasks.length > 0 ? (
        tasks.map((task) => (
          // key must be stable and unique among siblings. Use the data's id,
          // not the array index.
          <View key={task.id} style={styles.row}>
            <Text style={styles.title}>{task.title}</Text>

            {/* && renders the right side only when the left side is true.
                Guard with a boolean, never with a number. */}
            {task.done && <Text style={styles.done}>done</Text>}
          </View>
        ))
      ) : (
        <Text style={styles.empty}>Nothing to do</Text>
      )}
    </View>
  );
}

const styles = StyleSheet.create({
  screen: { flex: 1, paddingTop: 80, paddingHorizontal: 24 },
  row: { flexDirection: "row", justifyContent: "space-between", paddingVertical: 10 },
  title: { fontSize: 16 },
  done: { fontSize: 12, color: "#1B7F3B", fontWeight: "600" },
  empty: { fontSize: 16, color: "#666666" },
});

// On screen: two rows. "Pack charger" with a small green "done" on the right,
// and "Book taxi" with nothing on the right. Empty the tasks array and the
// screen shows only "Nothing to do".

When to use it

  • Show an empty state when a list has no items
  • Render a completed marker only on the tasks that are done
  • Turn a constant array of options into a column of rows
  • Hide a section of a screen until the data it needs exists

More examples

The zero trap, and the fix

With the array empty the screen shows only "Inbox zero". The commented line is the single most common crash in a React Native list screen: on the web the stray 0 merely appears as text, here it throws, so the habit of comparing rather than relying on truthiness matters more. An empty string behaves the same way.

Example · tsx
import { StyleSheet, Text, View } from "react-native";

const unread: string[] = [];

export default function Inbox() {
  return (
    <View style={styles.screen}>
      {/* WRONG, left commented because it crashes the screen:
          unread.length is 0, && returns 0, and a bare 0 under a <View>
          throws "Text strings must be rendered within a <Text> component".
      {unread.length && <Text>{unread.length} unread</Text>}
      */}

      {/* RIGHT: compare first, so the left side is a real boolean. */}
      {unread.length > 0 && <Text>{unread.length} unread</Text>}

      {/* Or state both branches, which also gives you somewhere to put
          the empty case. */}
      {unread.length > 0 ? (
        <Text style={styles.body}>You have mail</Text>
      ) : (
        <Text style={styles.body}>Inbox zero</Text>
      )}
    </View>
  );
}

const styles = StyleSheet.create({
  screen: { flex: 1, paddingTop: 80, paddingHorizontal: 24, gap: 8 },
  body: { fontSize: 16, color: "#333333" },
});

An early return for a whole-screen branch

Two lines, "Porto" and "Vigo"; pass an empty array and the component renders "No trips booked" instead. Reach for the early return when the two branches share nothing, and for a ternary when they differ in one small place. Returning a separate component for the empty case keeps both paths short.

Example · tsx
import { StyleSheet, Text, View } from "react-native";

type Trip = { id: string; city: string };

function EmptyState() {
  return <Text style={styles.empty}>No trips booked</Text>;
}

function TripList({ trips }: { trips: Trip[] }) {
  // Handle the empty case and leave. Easier to read than wrapping the whole
  // return in a ternary. When hooks appear later in the course they must sit
  // above a return like this one, because they run in the same order every
  // render.
  if (trips.length === 0) return <EmptyState />;

  return (
    <View>
      {trips.map((trip) => (
        <Text key={trip.id} style={styles.row}>
          {trip.city}
        </Text>
      ))}
    </View>
  );
}

export default function App() {
  const trips: Trip[] = [
    { id: "a1", city: "Porto" },
    { id: "a2", city: "Vigo" },
  ];

  return (
    <View style={styles.screen}>
      <TripList trips={trips} />
    </View>
  );
}

const styles = StyleSheet.create({
  screen: { flex: 1, paddingTop: 80, paddingHorizontal: 24 },
  row: { fontSize: 18, paddingVertical: 8 },
  empty: { fontSize: 16, color: "#666666" },
});

Keys that survive a change, and the one safe use of the index

Two part names in normal type, then three small grey legend lines. It shows the decision rather than a rule to memorise: ask whether an item's position can change, and if it can, the index is not an identity. Keys are never visible on screen and are not available inside the component as a prop.

Example · tsx
import { StyleSheet, Text, View } from "react-native";

type Part = { sku: string; name: string };

const parts: Part[] = [
  { sku: "SKU-1", name: "Tripod" },
  { sku: "SKU-2", name: "Filter" },
];

// A fixed array of plain strings that is never sorted, filtered or added to.
const legend = ["in stock", "backorder", "discontinued"];

export default function Stock() {
  return (
    <View style={styles.screen}>
      {parts.map((part) => (
        // The id from the data. It survives sorting, insertion and deletion,
        // so React keeps each row attached to its own item.
        <Text key={part.sku} style={styles.row}>
          {part.name}
        </Text>
      ))}

      {legend.map((label, index) => (
        // Acceptable here only because this array is a constant and its
        // order can never change. Any list that can change needs a real id.
        <Text key={index} style={styles.legend}>
          {label}
        </Text>
      ))}
    </View>
  );
}

const styles = StyleSheet.create({
  screen: { flex: 1, paddingTop: 80, paddingHorizontal: 24 },
  row: { fontSize: 18, paddingVertical: 6 },
  legend: { fontSize: 12, color: "#888888" },
});

Discussion

  • Be the first to comment on this lesson.