Ojasa Mirai

Ojasa Mirai

ReactJS

Loading...

Learning Level

🟢 Beginner🔵 Advanced
🎁 What are Props?📤 Passing Props to Components🔍 Reading Props in Components🎯 Props for Customization✅ Prop Types & Validation🔄 Props vs State⬇️ Passing Functions as Props🚀 Building Reusable Components
Reactjs/Props/Function Props

⬇️ Advanced Callback Props & Event Handling — Mastering Component Communication

Advanced callback patterns enable complex component interactions without prop drilling. Proper memoization, event delegation, and callback composition create performant, maintainable component systems.


🎯 Callback Memoization and Performance Optimization

Callback functions must maintain stable identity across renders to prevent unnecessary re-renders in memoized child components. `useCallback` solves this while dependency management ensures correctness.

Callback identity matters when children use memos. If a parent creates a new callback function every render, memoized children re-render even if logic hasn't changed. `useCallback` creates a stable identity and only updates when dependencies change.

import { memo, useCallback, useState } from "react";

// Parent component with callback
function ListContainer() {
  const [items, setItems] = useState<string[]>([]);
  const [selectedId, setSelectedId] = useState<string | null>(null);

  // Without useCallback: new function every render, causes child re-renders
  const handleItemClick = (id: string) => {
    setSelectedId(id);
  };

  // With useCallback: stable identity, child only re-renders if dependencies change
  const memoizedHandleClick = useCallback(
    (id: string) => {
      setSelectedId(id);
    },
    [] // No dependencies - callback never changes
  );

  return (
    <div>
      <ItemList
        items={items}
        selectedId={selectedId}
        onItemClick={memoizedHandleClick}
      />
    </div>
  );
}

interface ItemListProps {
  items: string[];
  selectedId: string | null;
  onItemClick: (id: string) => void;
}

// Memoized child - only re-renders if props change
const ItemList = memo<ItemListProps>(
  ({ items, selectedId, onItemClick }) => {
    console.log("ItemList render");
    return (
      <div>
        {items.map((item) => (
          <Item
            key={item}
            id={item}
            isSelected={selectedId === item}
            onClick={onItemClick}
          />
        ))}
      </div>
    );
  },
  (prev, next) => {
    // Custom comparison: true = skip re-render
    return (
      prev.items === next.items &&
      prev.selectedId === next.selectedId &&
      prev.onItemClick === next.onItemClick // Stable identity matters!
    );
  }
);

interface ItemProps {
  id: string;
  isSelected: boolean;
  onClick: (id: string) => void;
}

const Item = memo<ItemProps>(({ id, isSelected, onClick }) => (
  <div
    onClick={() => onClick(id)}
    style={{
      padding: "8px",
      backgroundColor: isSelected ? "#e0e7ff" : "white",
    }}
  >
    {id}
  </div>
));

// Dependency tracking: callback updates when dependencies change
function FilteredListContainer() {
  const [items, setItems] = useState<{ id: string; category: string }[]>([]);
  const [filter, setFilter] = useState("");

  // Callback dependency on filter ensures it updates
  const handleFilter = useCallback(
    (category: string) => {
      setFilter(category);
      // filter is in dependency array, so this updates when filter changes
    },
    [filter] // Callback recreated when filter changes
  );

  return (
    <FilteredList items={items} onFilter={handleFilter} />
  );
}

<details>

<summary>📚 More Examples</summary>

// Example 2: Complex callback with multiple dependencies
interface DataFetcherProps {
  userId: string;
  filters: Record<string, any>;
  onDataLoad: (data: any) => void;
  onError: (error: Error) => void;
}

function DataFetcher({
  userId,
  filters,
  onDataLoad,
  onError,
}: DataFetcherProps) {
  const [isLoading, setIsLoading] = useState(false);

  // Callback with multiple dependencies
  const handleFetch = useCallback(async () => {
    setIsLoading(true);
    try {
      const response = await fetch(
        `/api/users/${userId}/data?${new URLSearchParams(filters)}`
      );
      const data = await response.json();
      onDataLoad(data);
    } catch (error) {
      onError(error as Error);
    } finally {
      setIsLoading(false);
    }
  }, [userId, filters, onDataLoad, onError]); // All deps matter!

  useEffect(() => {
    handleFetch();
  }, [handleFetch]);

  return <div>{isLoading && "Loading..."}</div>;
}

// Memoize parent callbacks to prevent child re-fetches
function UserDataContainer({ userId }: { userId: string }) {
  const [data, setData] = useState(null);
  const [error, setError] = useState<Error | null>(null);

  const memoizedSetData = useCallback(setData, []);
  const memoizedSetError = useCallback(setError, []);

  return (
    <DataFetcher
      userId={userId}
      filters={{}}
      onDataLoad={memoizedSetData}
      onError={memoizedSetError}
    />
  );
}

</details>

💡 Event Delegation and Callback Composition

Event delegation reduces prop drilling and improves performance by handling events at a higher level in the component tree.

Event delegation works by attaching listeners to parent elements and determining target from event object. This reduces the number of event listeners and enables dynamic child handling without re-renders.

import { SyntheticEvent } from "react";

// Traditional: prop drilling callbacks to each item
function TraditionalList({
  items,
}: {
  items: Array<{ id: string; name: string }>;
}) {
  const handleItemClick = (id: string) => {
    console.log("Clicked item:", id);
  };

  const handleItemDelete = (id: string) => {
    console.log("Deleting item:", id);
  };

  return (
    <div>
      {items.map((item) => (
        <ListItem
          key={item.id}
          item={item}
          onClick={() => handleItemClick(item.id)}
          onDelete={() => handleItemDelete(item.id)}
        />
      ))}
    </div>
  );
}

// Delegated: single handler at parent level
function DelegatedList({
  items,
}: {
  items: Array<{ id: string; name: string }>;
}) {
  const handleListClick = (e: React.MouseEvent<HTMLDivElement>) => {
    const target = e.target as HTMLElement;
    const action = target.dataset.action;
    const itemId = target.dataset.itemId;

    if (!action || !itemId) return;

    switch (action) {
      case "click":
        console.log("Clicked item:", itemId);
        break;
      case "delete":
        console.log("Deleting item:", itemId);
        break;
    }
  };

  return (
    <div onClick={handleListClick}>
      {items.map((item) => (
        <div key={item.id} data-item-id={item.id}>
          <span>{item.name}</span>
          <button data-action="delete" data-item-id={item.id}>
            Delete
          </button>
        </div>
      ))}
    </div>
  );
}

// Callback composition: combining callbacks
function handleError(error: Error) {
  console.error(error);
}

function handleSuccess(data: any) {
  console.log("Success:", data);
}

// Compose handlers
const handleComplete = (error: Error | null, data: any) => {
  if (error) {
    handleError(error);
  } else {
    handleSuccess(data);
  }
};

// Create higher-order callback composers
function composeCallbacks<Args extends any[]>(
  ...callbacks: Array<(...args: Args) => void>
) {
  return (...args: Args) => {
    callbacks.forEach((cb) => cb(...args));
  };
}

const handleMultipleActions = composeCallbacks(
  (data: any) => console.log("Log:", data),
  (data: any) => updateUI(data),
  (data: any) => sendAnalytics(data)
);

<details>

<summary>📚 More Examples</summary>

// Example 2: Callback adapter pattern for API compatibility
interface LegacyCallbackProps {
  onSuccess: (data: any) => void;
  onError: (error: string) => void;
}

interface ModernCallbackProps {
  onComplete: (result: { status: "success" | "error"; data?: any; error?: Error }) => void;
}

// Adapter: convert modern callback to legacy
function adaptCallback(
  onComplete: ModernCallbackProps["onComplete"]
): LegacyCallbackProps {
  return {
    onSuccess: (data) =>
      onComplete({ status: "success", data }),
    onError: (error) =>
      onComplete({ status: "error", error: new Error(error) }),
  };
}

// Use legacy component with modern callback
function ModernComponent() {
  const handleComplete = (result: any) => {
    if (result.status === "success") {
      console.log("Got data:", result.data);
    } else {
      console.error("Error:", result.error);
    }
  };

  const legacyProps = adaptCallback(handleComplete);

  return <LegacyComponent {...legacyProps} />;
}

</details>

🔧 Type-Safe Callbacks with TypeScript

TypeScript enables type-safe callbacks that catch errors at compile time. Generics allow flexible callback signatures while maintaining safety.

// Type-safe callback definition
type ChangeHandler<T> = (value: T) => void;
type SubmitHandler<T> = (data: T) => Promise<void>;
type ErrorHandler = (error: Error) => void;

interface FormFieldProps<T> {
  value: T;
  onChange: ChangeHandler<T>;
  onError?: ErrorHandler;
}

function FormField<T>({
  value,
  onChange,
  onError,
}: FormFieldProps<T>) {
  return (
    <input
      value={String(value)}
      onChange={(e) => {
        try {
          onChange(e.target.value as T);
        } catch (error) {
          onError?.(error as Error);
        }
      }}
    />
  );
}

// Generic form handler
interface FormState {
  email: string;
  password: string;
  age: number;
}

function LoginForm() {
  const [form, setForm] = useState<FormState>({
    email: "",
    password: "",
    age: 0,
  });

  const handleFieldChange = useCallback<ChangeHandler<any>>(
    (value) => {
      // Determine which field changed based on value type
      if (typeof value === "string") {
        setForm((f) => ({ ...f, email: value }));
      }
    },
    []
  );

  const handleSubmit = useCallback<SubmitHandler<FormState>>(
    async (data) => {
      await api.login(data);
    },
    []
  );

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        handleSubmit(form);
      }}
    >
      <FormField value={form.email} onChange={handleFieldChange} />
    </form>
  );
}

// Callback reducer pattern: type-safe state updates through callbacks
type Action<T> = { type: "set"; payload: T } | { type: "reset" } | { type: "undo" };

interface CallbackReducerProps<T> {
  initialValue: T;
  onAction: (action: Action<T>) => void;
}

function useCallbackReducer<T>(initialValue: T) {
  const [value, setValue] = useState(initialValue);
  const [history, setHistory] = useState<T[]>([initialValue]);

  const dispatch = useCallback((action: Action<T>) => {
    switch (action.type) {
      case "set":
        setValue(action.payload);
        setHistory((h) => [...h, action.payload]);
        break;
      case "reset":
        setValue(initialValue);
        setHistory([initialValue]);
        break;
      case "undo":
        if (history.length > 1) {
          const newHistory = history.slice(0, -1);
          setValue(newHistory[newHistory.length - 1]);
          setHistory(newHistory);
        }
        break;
    }
  }, [initialValue, history]);

  return { value, dispatch };
}

<details>

<summary>📚 More Examples</summary>

// Example 2: Async callback with proper typing
type AsyncCallback<T, R = void> = (data: T) => Promise<R>;

interface AsyncButtonProps<T> {
  onClick: AsyncCallback<T>;
  data: T;
  isLoading?: boolean;
  disabled?: boolean;
}

function AsyncButton<T>({
  onClick,
  data,
  isLoading = false,
  disabled = false,
}: AsyncButtonProps<T>) {
  const [loading, setLoading] = useState(false);

  const handleClick = useCallback(async () => {
    setLoading(true);
    try {
      await onClick(data);
    } finally {
      setLoading(false);
    }
  }, [onClick, data]);

  return (
    <button onClick={handleClick} disabled={disabled || loading || isLoading}>
      {loading ? "Loading..." : "Submit"}
    </button>
  );
}

</details>

🎨 Callback Performance Patterns

// Throttle callback to limit execution frequency
function useThrottledCallback<Args extends any[]>(
  callback: (...args: Args) => void,
  delay: number
) {
  const timeoutRef = useRef<NodeJS.Timeout | null>(null);

  return useCallback(
    (...args: Args) => {
      if (timeoutRef.current) return;

      callback(...args);
      timeoutRef.current = setTimeout(() => {
        timeoutRef.current = null;
      }, delay);
    },
    [callback, delay]
  );
}

// Debounce callback to delay execution
function useDebouncedCallback<Args extends any[]>(
  callback: (...args: Args) => void,
  delay: number
) {
  const timeoutRef = useRef<NodeJS.Timeout | null>(null);

  useEffect(() => {
    return () => {
      if (timeoutRef.current) clearTimeout(timeoutRef.current);
    };
  }, []);

  return useCallback(
    (...args: Args) => {
      if (timeoutRef.current) clearTimeout(timeoutRef.current);
      timeoutRef.current = setTimeout(() => callback(...args), delay);
    },
    [callback, delay]
  );
}

🔑 Key Takeaways

  • ✅ Use `useCallback` to maintain callback identity across renders
  • ✅ Include all dependencies in useCallback dependency array
  • ✅ Memoized children require stable callback props to prevent re-renders
  • ✅ Event delegation reduces prop drilling and improves performance
  • ✅ Callback composition enables combining multiple handlers
  • ✅ TypeScript enables compile-time callback safety
  • ✅ Throttle and debounce callbacks for performance-critical operations
  • ✅ Async callbacks require proper loading state management

Ready to practice? Challenges | Next: Enterprise Component Design


Resources

Python Docs

Ojasa Mirai

Master AI-powered development skills through structured learning, real projects, and verified credentials. Whether you're upskilling your team or launching your career, we deliver the skills companies actually need.

Learn Deep • Build Real • Verify Skills • Launch Forward

Courses

PythonFastapiReactJSCloud

© 2026 Ojasa Mirai. All rights reserved.

TwitterGitHubLinkedIn