logodev atlas
6 min read

TypeScript with React

Component Typing

Function Components

tsximport React from 'react';

// Props interface
interface ButtonProps {
  label: string;
  onClick: () => void;
  disabled?: boolean;
  variant?: 'primary' | 'secondary' | 'danger';
  children?: React.ReactNode;
}

// FC<Props> vs explicit return type
const Button: React.FC<ButtonProps> = ({ label, onClick, disabled = false, variant = 'primary' }) => {
  return (
    <button
      onClick={onClick}
      disabled={disabled}
      className={`btn btn-${variant}`}
    >
      {label}
    </button>
  );
};

// Preferred: explicit return type (avoids implicit children in FC)
function Card({ title, children }: { title: string; children: React.ReactNode }): React.JSX.Element {
  return <div className="card"><h2>{title}</h2>{children}</div>;
}

ReactNode vs ReactElement vs JSX.Element

ts// React.ReactNode — broadest: any renderable value
type ReactNode = ReactElement | string | number | boolean | null | undefined | ReactFragment;

// React.ReactElement — JSX element (result of React.createElement)
// React.JSX.Element — same as ReactElement, preferred in newer code

// Use ReactNode for children (accepts everything renderable)
interface WrapperProps { children: React.ReactNode; }

// Use ReactElement when you need to clone/manipulate the element
function enhance(child: React.ReactElement): React.ReactElement {
  return React.cloneElement(child, { className: 'enhanced' });
}

Hooks Typing

useState

tsx// TypeScript infers type from initial value
const [count, setCount] = React.useState(0);        // State<number>
const [name, setName] = React.useState('');          // State<string>

// Explicit type when initial is null/undefined
const [user, setUser] = React.useState<User | null>(null);

// Complex state
interface FormState {
  email: string;
  password: string;
  errors: Record<string, string>;
}
const [form, setForm] = React.useState<FormState>({
  email: '', password: '', errors: {},
});

// Functional update
setCount(prev => prev + 1);
setForm(prev => ({ ...prev, email: 'alice@example.com' }));

useRef

tsx// DOM ref
const inputRef = React.useRef<HTMLInputElement>(null);
// Access: inputRef.current?.focus() — note optional chaining (could be null before mount)

// Mutable value (not DOM)
const timerRef = React.useRef<ReturnType<typeof setInterval> | null>(null);
const countRef = React.useRef(0); // inferred as MutableRefObject<number>

// HTMLElement refs
const divRef = React.useRef<HTMLDivElement>(null);
const formRef = React.useRef<HTMLFormElement>(null);
const buttonRef = React.useRef<HTMLButtonElement>(null);

useReducer

tsxinterface CartState {
  items: CartItem[];
  total: number;
}

type CartAction =
  | { type: 'ADD_ITEM'; payload: CartItem }
  | { type: 'REMOVE_ITEM'; payload: string }  // payload = item id
  | { type: 'CLEAR_CART' }
  | { type: 'SET_QUANTITY'; payload: { id: string; qty: number } };

function cartReducer(state: CartState, action: CartAction): CartState {
  switch (action.type) {
    case 'ADD_ITEM':
      return { ...state, items: [...state.items, action.payload] };
    case 'REMOVE_ITEM':
      return { ...state, items: state.items.filter(i => i.id !== action.payload) };
    case 'CLEAR_CART':
      return { items: [], total: 0 };
    case 'SET_QUANTITY':
      return {
        ...state,
        items: state.items.map(i =>
          i.id === action.payload.id ? { ...i, qty: action.payload.qty } : i
        ),
      };
  }
}

const [cart, dispatch] = React.useReducer(cartReducer, { items: [], total: 0 });
dispatch({ type: 'ADD_ITEM', payload: { id: '1', name: 'Widget', qty: 1, price: 9.99 } });

useContext

tsxinterface AuthContextType {
  user: User | null;
  login: (credentials: Credentials) => Promise<void>;
  logout: () => void;
  isLoading: boolean;
}

// Pattern: undefined default to catch missing Provider
const AuthContext = React.createContext<AuthContextType | undefined>(undefined);

function useAuth(): AuthContextType {
  const context = React.useContext(AuthContext);
  if (!context) throw new Error('useAuth must be used within AuthProvider');
  return context;
}

function AuthProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = React.useState<User | null>(null);
  const [isLoading, setIsLoading] = React.useState(false);

  const login = async (credentials: Credentials) => {
    setIsLoading(true);
    try {
      const user = await authService.login(credentials);
      setUser(user);
    } finally {
      setIsLoading(false);
    }
  };

  const logout = () => setUser(null);

  return (
    <AuthContext.Provider value={{ user, login, logout, isLoading }}>
      {children}
    </AuthContext.Provider>
  );
}

Custom Hook Types

tsxinterface UseFetchResult<T> {
  data: T | null;
  loading: boolean;
  error: Error | null;
  refetch: () => void;
}

function useFetch<T>(url: string): UseFetchResult<T> {
  const [data, setData] = React.useState<T | null>(null);
  const [loading, setLoading] = React.useState(true);
  const [error, setError] = React.useState<Error | null>(null);

  const fetchData = React.useCallback(async () => {
    setLoading(true);
    setError(null);
    try {
      const res = await fetch(url);
      if (!res.ok) throw new Error(res.statusText);
      const json: T = await res.json();
      setData(json);
    } catch (e) {
      setError(e instanceof Error ? e : new Error(String(e)));
    } finally {
      setLoading(false);
    }
  }, [url]);

  React.useEffect(() => { fetchData(); }, [fetchData]);

  return { data, loading, error, refetch: fetchData };
}

// Usage — T inferred from generic
const { data: users, loading } = useFetch<User[]>('/api/users');
users?.[0].name; // TypeScript knows this is User

Event Handling

tsx// Form events
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
  console.log(e.target.value);
}

function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
  e.preventDefault();
  const data = new FormData(e.currentTarget);
}

// Mouse events
function handleClick(e: React.MouseEvent<HTMLButtonElement>) {
  console.log(e.currentTarget.id);
}

// Keyboard events
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
  if (e.key === 'Enter') submit();
}

// Drag events
function handleDrop(e: React.DragEvent<HTMLDivElement>) {
  const files = e.dataTransfer.files;
}

// Generic handler factory
function makeChangeHandler<T extends HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>(
  setter: (value: string) => void
) {
  return (e: React.ChangeEvent<T>) => setter(e.target.value);
}

forwardRef + useImperativeHandle

tsxinterface InputHandle {
  focus: () => void;
  clear: () => void;
  getValue: () => string;
}

interface InputProps {
  placeholder?: string;
  defaultValue?: string;
}

const FancyInput = React.forwardRef<InputHandle, InputProps>(
  ({ placeholder, defaultValue }, ref) => {
    const inputRef = React.useRef<HTMLInputElement>(null);
    const [value, setValue] = React.useState(defaultValue ?? '');

    React.useImperativeHandle(ref, () => ({
      focus: () => inputRef.current?.focus(),
      clear: () => setValue(''),
      getValue: () => value,
    }));

    return (
      <input
        ref={inputRef}
        value={value}
        onChange={e => setValue(e.target.value)}
        placeholder={placeholder}
        className="fancy-input"
      />
    );
  }
);
FancyInput.displayName = 'FancyInput';

// Usage
function Form() {
  const inputRef = React.useRef<InputHandle>(null);
  return (
    <>
      <FancyInput ref={inputRef} placeholder="Type here..." />
      <button onClick={() => inputRef.current?.focus()}>Focus</button>
      <button onClick={() => inputRef.current?.clear()}>Clear</button>
    </>
  );
}

Generic Components

tsx// Generic list component
interface ListProps<T> {
  items: T[];
  renderItem: (item: T, index: number) => React.ReactNode;
  keyExtractor: (item: T) => string | number;
  emptyComponent?: React.ReactNode;
}

function List<T>({ items, renderItem, keyExtractor, emptyComponent }: ListProps<T>) {
  if (items.length === 0) return <>{emptyComponent ?? <p>No items</p>}</>;
  return (
    <ul>
      {items.map((item, i) => (
        <li key={keyExtractor(item)}>{renderItem(item, i)}</li>
      ))}
    </ul>
  );
}

// Usage — T inferred from items
<List
  items={users}
  keyExtractor={u => u.id}
  renderItem={u => <UserCard user={u} />}
/>

// Generic select
interface SelectProps<T extends string | number> {
  options: Array<{ value: T; label: string }>;
  value: T;
  onChange: (value: T) => void;
}

function Select<T extends string | number>({ options, value, onChange }: SelectProps<T>) {
  return (
    <select value={value} onChange={e => onChange(e.target.value as T)}>
      {options.map(opt => (
        <option key={opt.value} value={opt.value}>{opt.label}</option>
      ))}
    </select>
  );
}

Discriminated Union Props

tsx// Component that behaves differently based on a discriminant
type ButtonProps =
  | { variant: 'link'; href: string; target?: '_blank' | '_self' }
  | { variant: 'button'; onClick: () => void; disabled?: boolean }
  | { variant: 'submit'; form?: string };

function SmartButton(props: ButtonProps) {
  if (props.variant === 'link') {
    return <a href={props.href} target={props.target}>{/* ... */}</a>;
  }
  if (props.variant === 'submit') {
    return <button type="submit" form={props.form}>{/* ... */}</button>;
  }
  return <button onClick={props.onClick} disabled={props.disabled}>{/* ... */}</button>;
}

// TypeScript narrows correctly in each branch

Polymorphic Components (as prop)

tsxtype AsProp<C extends React.ElementType> = { as?: C };

type PropsToOmit<C extends React.ElementType, P> = keyof (AsProp<C> & P);

type PolymorphicComponentProp<C extends React.ElementType, Props = object> =
  React.PropsWithChildren<Props & AsProp<C>> &
  Omit<React.ComponentPropsWithoutRef<C>, PropsToOmit<C, Props>>;

interface TextOwnProps { size?: 'sm' | 'md' | 'lg'; color?: string; }

type TextProps<C extends React.ElementType> = PolymorphicComponentProp<C, TextOwnProps>;

function Text<C extends React.ElementType = 'span'>({
  as,
  size = 'md',
  color,
  children,
  ...rest
}: TextProps<C>) {
  const Component = as ?? 'span';
  return (
    <Component className={`text-${size}`} style={{ color }} {...rest}>
      {children}
    </Component>
  );
}

// Usage — as prop changes the underlying element and available props
<Text as="h1" size="lg">Heading</Text>
<Text as="a" href="/about">Link</Text>  // href is available because as="a"
<Text as="button" onClick={fn}>Button</Text>

Common Type Utilities in React

ts// ComponentProps — extract props from any component
type ButtonProps = React.ComponentProps<'button'>;
type MyCompProps = React.ComponentProps<typeof MyComponent>;

// ComponentPropsWithRef / ComponentPropsWithoutRef
type DivPropsWithRef = React.ComponentPropsWithRef<'div'>;

// ElementRef — get ref type from a component
type InputRef = React.ElementRef<'input'>; // HTMLInputElement
type MyRef = React.ElementRef<typeof FancyInput>; // InputHandle

// CSSProperties — for inline style objects
const style: React.CSSProperties = {
  backgroundColor: 'red',
  fontSize: '16px',
};

// PropsWithChildren — add children to props
type MyProps = React.PropsWithChildren<{ title: string }>;

// Dispatch — type for useReducer dispatch
type CartDispatch = React.Dispatch<CartAction>;
[prev·next]