Typing Components and Hooks

Return types are inferred, generics make a list component reusable, and a hook's tuple needs as const to keep its positions typed.

Syntaxfunction OptionList<T>({ items }: OptionListProps<T>) { /* ... */ }

A component is a function that returns JSX, and TypeScript infers that return type. You almost never annotate it. React.FC<Props> is the older longhand for the same function; since @types/react 18 it no longer adds children for you, so a plain annotated function is the shorter and clearer form.

Generic components

A list or picker that should work for any element type takes a type parameter: function OptionList<T>({ items }: OptionListProps<T>). The caller fixes T by passing an array, and every callback prop then receives that exact element type - no any, no casting. In a .tsx file an arrow function needs the trailing comma, <T,>, because <T> alone parses as a JSX tag. A function declaration has no such problem.

Hook return values

A custom hook is a function whose name starts with use; its return type is inferred like any other. For a tuple, add as const: return [on, toggle] as const gives readonly [boolean, () => void]. Without it the inferred type is an array of the union of both, and destructuring loses the per-position types. Return an object once there are more than two values, or when the set may grow.

Refs

A ref's type is the component's instance type, which React Native exports beside the component: useRef<TextInputInstance | null>(null), with TextInputInstance imported as a type from react-native. React Native 0.87 makes the Strict TypeScript API the default, and under it the built-in components are typed as functions, so the component name no longer works as a ref type - useRef<TextInput> compiled before 0.87 and does not now. Every built-in has its own: ViewInstance, ScrollViewInstance, TextInputInstance; React.ComponentRef<typeof TextInput> is the same type spelled out. It is null until mount, so read it as current?.focus(). React 19, which Expo SDK 57 installs, passes ref to function components as an ordinary prop, so forwardRef is no longer needed and is deprecated.

Example

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

type OptionListProps<T> = {
  items: readonly T[];
  keyOf: (item: T) => string;
  label: (item: T) => string;
};

// A generic component. T is decided by the array the caller passes,
// so keyOf and label receive that same element type. A function
// declaration can be generic as written; an arrow would need <T,>.
export default function OptionList<T>({
  items,
  keyOf,
  label,
}: OptionListProps<T>) {
  return (
    <View style={styles.list}>
      {items.map((item) => (
        // key is a React prop, not part of our type
        <Text key={keyOf(item)} style={styles.row}>
          {label(item)}
        </Text>
      ))}
    </View>
  );
}

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

When to use it

  • Write one list component that keeps its item type for every screen that uses it.
  • Return [value, setValue] from a custom hook without the types collapsing to an array.
  • Focus a text field from a button somewhere else on the screen.
  • Replace React.FC<Props> in an older codebase with a plain typed function.

More examples

Using the generic component

Two lines of text, each a name followed by its role in brackets. Nothing in this file mentions the type parameter: passing items infers T = Member, and the two callbacks are checked against it, so member.rol would be flagged at once. This is how a reusable picker, chip row or option list avoids declaring its item type as any - and the same component works unchanged for a Product or a Message.

Example · tsx
// src/components/TeamScreen.tsx
import { StyleSheet, View } from 'react-native';
import OptionList from '@/components/OptionList';

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

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

export default function TeamScreen() {
  return (
    <View style={styles.screen}>
      <OptionList
        items={TEAM}
        // member is Member here, inferred from items - nothing declares it
        keyOf={(member) => member.id}
        label={(member) => `${member.name} (${member.role})`}
      />
    </View>
  );
}

const styles = StyleSheet.create({
  screen: { flex: 1, padding: 16 },
});

A hook returning a tuple, and the object alternative

Two shapes for the same idea. Callers destructure the tuple positionally, const [open, toggle] = useToggle(false), which is why useState itself returns a tuple; they destructure the object by name, const { open, hide } = useDisclosure(false). Pick the tuple for exactly two values that callers will rename, the object for anything likely to gain a third member. useCallback keeps the function identity stable across renders, which matters once it is passed to a child.

Example · tsx
// src/hooks/useToggle.ts
import { useCallback, useState } from 'react';

// Tuple form. 'as const' freezes the positions:
// readonly [boolean, () => void].
export function useToggle(initial: boolean) {
  const [on, setOn] = useState(initial);
  const toggle = useCallback(() => setOn((previous) => !previous), []);
  return [on, toggle] as const;
}

// Without 'as const' the return type is
// (boolean | (() => void))[] and both names lose their type.

// Object form: longer to read, but the fields are named and new ones
// can be added without breaking the order callers rely on.
export function useDisclosure(initial: boolean) {
  const [open, setOpen] = useState(initial);
  const show = useCallback(() => setOpen(true), []);
  const hide = useCallback(() => setOpen(false), []);
  return { open, show, hide };
}

A typed ref to a React Native component

A text field with a Focus label beside it; tapping the label puts the keyboard up and the cursor in the field. A ref is the escape hatch for imperative calls a prop cannot express - focus, blur, scrollTo, measure - and its type is the component's instance type, TextInputInstance, never the component itself: under the Strict TypeScript API that 0.87 enables by default the built-ins are functions, so useRef<TextInput> fails to compile, and React.ComponentRef<typeof TextInput> is the long way of writing the same instance type. Under React 19 you can also accept ref as a plain prop on your own component, typed Ref<TextInputInstance> from react, with no forwardRef wrapper.

Example · tsx
// src/components/SearchRow.tsx
import { useRef } from 'react';
import {
  Pressable,
  StyleSheet,
  Text,
  TextInput,
  View,
  type TextInputInstance,
} from 'react-native';

export default function SearchRow() {
  // The ref's type is the component's *instance* type, exported from
  // 'react-native' alongside the component. Under the Strict TypeScript
  // API - the default since 0.87 - TextInput is typed as a function, so
  // useRef<TextInput> no longer compiles.
  const inputRef = useRef<TextInputInstance | null>(null);

  return (
    <View style={styles.row}>
      <TextInput
        ref={inputRef}
        placeholder="Search"
        style={styles.input}
      />
      <Pressable onPress={() => inputRef.current?.focus()}>
        {/* current is null until the TextInput has mounted */}
        <Text style={styles.action}>Focus</Text>
      </Pressable>
    </View>
  );
}

const styles = StyleSheet.create({
  row: { flexDirection: 'row', alignItems: 'center', gap: 12, padding: 16 },
  input: { flex: 1, borderWidth: 1, borderColor: '#cccccc', borderRadius: 8, padding: 8 },
  action: { color: '#1f6feb', fontWeight: '600' },
});

Discussion

  • Be the first to comment on this lesson.