Typing Props and State

Props are one object parameter: type it, mark optional keys with ?, type children as ReactNode, and give useState(null) a generic.

Syntaxtype Props = { title: string; footer?: string; children: ReactNode }

Props are the first parameter of a component function, so typing props is typing one object. Declare a type, annotate the parameter, destructure in place. No React-specific syntax is involved.

Optional and default

A ? after a key makes it optional: footer?: string. That is shorthand for a key that may be absent and a value that may be undefined. The longhand footer: string | undefined is not the same thing - it still forces every caller to pass the prop. A default value belongs in the destructuring: { footer = 'None' } is exactly const footer = props.footer === undefined ? 'None' : props.footer, so it fires on undefined only, never on null or 0.

children

Nothing is implicit. A component that wraps other content must declare it. The type is ReactNode from react: elements, strings, numbers, arrays, null. ReactElement is narrower and rejects a plain string. Note that a string passed as children still needs a <Text> around it when you render it.

State

useState infers from its initial value, which is why useState(0) needs no annotation. useState(null) infers the type null, and the first real value you set is then a compile error. Pass the generic instead: useState<Member | null>(null). With strict on, the compiler then makes you handle the empty case before reading a field - usually with ?..

Example

Example · tsx
// src/components/Card.tsx
import type { ReactNode } from 'react';
import { StyleSheet, Text, View } from 'react-native';

type CardProps = {
  title: string;
  // ReactNode is anything React can render: elements, strings,
  // numbers, arrays, null. ReactElement would allow only an element.
  children: ReactNode;
  // '?' means the key may be missing. The longhand
  // 'footer: string | undefined' would still demand the prop.
  footer?: string;
};

export default function Card({ title, children, footer = 'No footer' }: CardProps) {
  return (
    <View style={styles.card}>
      <Text style={styles.title}>{title}</Text>
      {children}
      <Text style={styles.footer}>{footer}</Text>
    </View>
  );
}

const styles = StyleSheet.create({
  card: {
    padding: 16,
    borderRadius: 12,
    backgroundColor: '#ffffff',
    // Shadows are platform-split: shadow* on iOS, elevation on Android
    elevation: 2,
    shadowOpacity: 0.1,
    shadowRadius: 4,
  },
  title: { fontSize: 18, fontWeight: '600' },
  footer: { fontSize: 12, color: '#666666', marginTop: 8 },
});

When to use it

  • Give a reusable Card or Button a props type so misuse fails before the app reloads.
  • Let a wrapper component accept children without losing type checking.
  • Hold a selected item that starts as nothing without the compiler fighting you.
  • Extend Pressable or View props instead of redeclaring every prop you pass through.

More examples

State that starts empty

Two rows with a line beneath them; tapping a row replaces the name on that line, which reads "nobody" until the first tap. The union Member | null is the honest type for anything not chosen yet, and strict mode turns every later read into a question you have to answer. A fixed pair of rows is fine with map; a list of unknown length belongs in a FlatList, covered in Stage 3.

Example · tsx
// src/components/TeamPicker.tsx
import { useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';

type Member = { id: string; name: string };

const TEAM: Member[] = [
  { id: 'a', name: 'Ada' },
  { id: 'b', name: 'Grace' },
];

export default function TeamPicker() {
  // Without the generic this would infer 'null' and the setter below
  // would be rejected.
  const [selected, setSelected] = useState<Member | null>(null);

  return (
    <View style={styles.screen}>
      {TEAM.map((member) => (
        <Pressable key={member.id} onPress={() => setSelected(member)}>
          <Text style={styles.row}>{member.name}</Text>
        </Pressable>
      ))}
      {/* selected may be null, so read through it with ?. */}
      <Text>Selected: {selected?.name ?? 'nobody'}</Text>
    </View>
  );
}

const styles = StyleSheet.create({
  screen: { padding: 16, gap: 8 },
  row: { fontSize: 16 },
});

Building on a built-in component's props

A blue pill with centred white text. React Native exports a props type for each component - PressableProps, ViewProps, TextProps - so a wrapper can accept the full set without you retyping it. Rest-spreading them through is the usual pattern for a design-system button; the ordering comment matters, because the last style in JSX order is the one that applies.

Example · tsx
// src/components/LabelButton.tsx
import { Pressable, StyleSheet, Text, type PressableProps } from 'react-native';

// Everything Pressable accepts (onPress, disabled, hitSlop, ...) plus
// one prop of our own. Omit drops children so 'label' owns the content.
type LabelButtonProps = Omit<PressableProps, 'children'> & {
  label: string;
};

export default function LabelButton({ label, ...rest }: LabelButtonProps) {
  return (
    // style comes before {...rest}, so a caller's style prop wins
    <Pressable style={styles.button} {...rest}>
      <Text style={styles.label}>{label}</Text>
    </Pressable>
  );
}

const styles = StyleSheet.create({
  button: {
    paddingHorizontal: 16,
    paddingVertical: 10,
    borderRadius: 8,
    backgroundColor: '#1f6feb',
  },
  label: { color: '#ffffff', fontWeight: '600', textAlign: 'center' },
});

A function prop, typed by its signature

A minus, a number and a plus in a row - flexDirection: 'row' is needed because column is the default. The parent owns the value, so a caller writes onChange={setCount} with setCount from useState<number>; that shorthand is the same as onChange={(next) => setCount(next)}, because the setter already has the signature (next: number) => void. Typing the prop this way means passing a handler that takes a string will not compile.

Example · tsx
// src/components/Stepper.tsx
import { Pressable, StyleSheet, Text, View } from 'react-native';

type StepperProps = {
  value: number;
  // The type of a callback prop is just the function's shape:
  // takes a number, returns nothing.
  onChange: (next: number) => void;
};

export default function Stepper({ value, onChange }: StepperProps) {
  return (
    <View style={styles.row}>
      <Pressable onPress={() => onChange(value - 1)}>
        <Text style={styles.button}>-</Text>
      </Pressable>
      <Text style={styles.value}>{value}</Text>
      <Pressable onPress={() => onChange(value + 1)}>
        <Text style={styles.button}>+</Text>
      </Pressable>
    </View>
  );
}

const styles = StyleSheet.create({
  row: { flexDirection: 'row', alignItems: 'center', gap: 12 },
  button: { fontSize: 24, width: 32, textAlign: 'center' },
  value: { fontSize: 18, minWidth: 40, textAlign: 'center' },
});

Discussion

  • Be the first to comment on this lesson.