Typing Refs and Context

Difficulty: Advanced

React's useRef hook serves two distinct purposes, and each requires different typing in TypeScript. When used to reference a DOM element, you pass null as the initial value and type it as `useRef<HTMLDivElement>(null)`. This creates a `RefObject` with a readonly `current` property. When used as a mutable instance variable (to store values that persist across renders without triggering re-renders), you include null in the type union or provide a non-null initial value, creating a `MutableRefObject`.

The key difference between `RefObject<T>` and `MutableRefObject<T>` is that RefObject's `current` is readonly - you cannot reassign it because React manages the DOM reference. MutableRefObject's `current` is read-write, allowing you to store and update values freely. TypeScript determines which one you get based on whether null is part of the generic type parameter or just the initial value.

React's createContext is a generic function that requires a default value. In TypeScript, the type of the context is inferred from the default value, or you can specify it explicitly: `createContext<ThemeContext | undefined>(undefined)`. The challenge is that if the default is undefined, every consumer must check for undefined before using the context. A common pattern is to create a custom hook that throws an error if the context is undefined, eliminating the need for null checks at every consumption point.

Ref forwarding with TypeScript uses `React.forwardRef<RefType, PropsType>`, where the first generic parameter is the type of the forwarded ref and the second is the props type. This is essential for library components that need to expose their underlying DOM element to parent components. The function passed to forwardRef receives props and ref as separate arguments, and the ref is typed as `React.ForwardedRef<RefType>`.

Code examples

useRef for DOM Elements and Mutable Values

// Simulating useRef behavior
interface RefObject<T> {
  readonly current: T | null;
}

interface MutableRefObject<T> {
  current: T;
}

// DOM ref - readonly current, managed by React
function useRef<T>(initial: T | null): T extends null ? RefObject<T> : MutableRefObject<T>;
function useRef<T>(initial: T | null): any {
  return { current: initial };
}

// DOM reference (current is readonly in real React)
const divRef = useRef<HTMLDivElement>(null);
console.log(`DOM ref initial: ${divRef.current}`);

// Mutable ref for storing values
const renderCount: MutableRefObject<number> = { current: 0 };
renderCount.current += 1;
renderCount.current += 1;
renderCount.current += 1;
console.log(`Render count: ${renderCount.current}`);

// Timer ref pattern
const timerRef: MutableRefObject<number | null> = { current: null };
timerRef.current = 42; // simulating setTimeout return
console.log(`Timer ID: ${timerRef.current}`);
timerRef.current = null; // cleared
console.log(`Timer cleared: ${timerRef.current}`);

DOM refs use RefObject with readonly current. Mutable refs (for counters, timer IDs, etc.) use MutableRefObject where current is writable.

Typed Context with Custom Hook

// Simulating React Context pattern
interface ThemeContext {
  theme: 'light' | 'dark';
  toggleTheme: () => void;
}

// Creating context with undefined default
let contextValue: ThemeContext | undefined = undefined;

function createContext<T>(defaultValue: T): { value: T } {
  return { value: defaultValue };
}

// Custom hook that throws if context is undefined
function useTheme(): ThemeContext {
  if (contextValue === undefined) {
    throw new Error('useTheme must be used within ThemeProvider');
  }
  return contextValue;
}

// Simulate providing context
contextValue = {
  theme: 'dark',
  toggleTheme: () => { console.log('Theme toggled'); }
};

// Now useTheme returns non-undefined type - no null checks needed
const theme = useTheme();
console.log(`Current theme: ${theme.theme}`);
theme.toggleTheme();

// Demonstrate type safety
console.log(`Theme is dark: ${theme.theme === 'dark'}`);

A custom hook that throws when context is undefined eliminates null checks at every consumption point. Consumers get a guaranteed non-undefined context value.

Generic Context Factory

// Reusable context factory pattern
function createSafeContext<T>(name: string): {
  provider: (value: T) => void;
  use: () => T;
} {
  let value: T | undefined = undefined;

  return {
    provider(val: T): void {
      value = val;
    },
    use(): T {
      if (value === undefined) {
        throw new Error(`${name} context not provided`);
      }
      return value;
    }
  };
}

// Create typed contexts
interface AuthContext {
  user: string;
  isLoggedIn: boolean;
}

const authContext = createSafeContext<AuthContext>('Auth');
authContext.provider({ user: 'Alice', isLoggedIn: true });

const auth = authContext.use();
console.log(`User: ${auth.user}`);
console.log(`Logged in: ${auth.isLoggedIn}`);

// Number context
const counterContext = createSafeContext<number>('Counter');
counterContext.provider(42);
console.log(`Counter: ${counterContext.use()}`);

A generic context factory creates type-safe contexts with built-in undefined checks. This pattern reduces boilerplate when creating multiple contexts.

Key points

Concepts covered

useRef types, createContext, generics with context, ref forwarding, MutableRefObject, RefObject, context default values