DEV Community

Tarun Moorjani
Tarun Moorjani

Posted on

Render Props vs Hooks vs HOCs: The TypeScript Perspective

I've refactored the same piece of logic three different ways in my career:

2016: Higher-Order Components everywhere

export default withAuth(withTheme(withRouter(MyComponent)));
Enter fullscreen mode Exit fullscreen mode

2018: Render Props for everything

<Auth>
  {({ user }) => (
    <Theme>
      {({ theme }) => (
        <Router>
          {({ navigate }) => (
            <MyComponent user={user} theme={theme} navigate={navigate} />
          )}
        </Router>
      )}
    </Theme>
  )}
</Auth>
Enter fullscreen mode Exit fullscreen mode

2020: Hooks have won

const user = useAuth();
const theme = useTheme();
const navigate = useNavigate();
return <MyComponent user={user} theme={theme} navigate={navigate} />;
Enter fullscreen mode Exit fullscreen mode

Each pattern emerged to solve real problems. Each has trade-offs. And TypeScript changes the equation significantly.

Let me show you which pattern to use when, and how TypeScript makes (or breaks) each approach.

The Evolution: Why We Have Three Patterns

Higher-Order Components (2015-2018)

Problem they solved: Component logic reuse before hooks
Pattern: Wrap components to inject props
Peak usage: React 15-16 era

Render Props (2017-2019)

Problem they solved: HOC limitations (wrapper hell, prop naming collisions)
Pattern: Pass render functions as props
Peak usage: React 16 era

Hooks (2019-Present)

Problem they solved: Both HOC and Render Props limitations
Pattern: Compose logic with functions, not components
Peak usage: React 16.8+

Pattern 1: Higher-Order Components (HOCs)

Basic HOC with TypeScript

// withAuth.tsx
import { ComponentType } from 'react';

interface User {
  id: string;
  name: string;
  email: string;
}

interface WithAuthProps {
  user: User;
  logout: () => void;
}

// ✅ Type-safe HOC
export function withAuth<P extends object>(
  Component: ComponentType<P & WithAuthProps>
): ComponentType<P> {
  return function AuthenticatedComponent(props: P) {
    const user = useAuth();
    const logout = useLogout();

    if (!user) {
      return <div>Please log in</div>;
    }

    return <Component {...props} user={user} logout={logout} />;
  };
}

// Usage
interface DashboardProps {
  title: string;
}

function Dashboard({ title, user, logout }: DashboardProps & WithAuthProps) {
  return (
    <div>
      <h1>{title}</h1>
      <p>Welcome, {user.name}</p>
      <button onClick={logout}>Logout</button>
    </div>
  );
}

export default withAuth(Dashboard);

// TypeScript knows Dashboard needs 'title' prop
// TypeScript knows 'user' and 'logout' are injected
<Dashboard title="My Dashboard" />
Enter fullscreen mode Exit fullscreen mode

Advanced: Generic HOC

// withData.tsx
interface WithDataProps<T> {
  data: T;
  isLoading: boolean;
  error: Error | null;
}

export function withData<T, P extends object>(
  fetchData: () => Promise<T>
) {
  return function (
    Component: ComponentType<P & WithDataProps<T>>
  ): ComponentType<P> {
    return function DataComponent(props: P) {
      const [data, setData] = useState<T | null>(null);
      const [isLoading, setIsLoading] = useState(true);
      const [error, setError] = useState<Error | null>(null);

      useEffect(() => {
        fetchData()
          .then(setData)
          .catch(setError)
          .finally(() => setIsLoading(false));
      }, []);

      if (isLoading) return <div>Loading...</div>;
      if (error) return <div>Error: {error.message}</div>;
      if (!data) return null;

      return <Component {...props} data={data} isLoading={false} error={null} />;
    };
  };
}

// Usage - full type inference!
interface User {
  id: string;
  name: string;
}

interface UserListProps {
  filter: string;
}

function UserList({ 
  filter, 
  data, 
  isLoading 
}: UserListProps & WithDataProps<User[]>) {
  const filtered = data.filter(user => 
    user.name.includes(filter)
  );

  return (
    <ul>
      {filtered.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

export default withData<User[]>(fetchUsers)(UserList);
Enter fullscreen mode Exit fullscreen mode

Composing Multiple HOCs

// The infamous wrapper hell
export default withAuth(
  withTheme(
    withRouter(
      withData(fetchUsers)(
        UserList
      )
    )
  )
);

// Better: compose utility
function compose<P extends object>(
  ...hocs: Array<(component: ComponentType<any>) => ComponentType<any>>
) {
  return (component: ComponentType<P>) =>
    hocs.reduceRight(
      (acc, hoc) => hoc(acc),
      component
    );
}

// Much cleaner
export default compose(
  withAuth,
  withTheme,
  withRouter,
  withData(fetchUsers)
)(UserList);
Enter fullscreen mode Exit fullscreen mode

HOC Pros and Cons

Pros:

  • ✅ Logic encapsulation
  • ✅ Can modify props
  • ✅ Works with class components
  • ✅ Clear dependency chain

Cons with TypeScript:

  • ❌ Type inference can break with multiple HOCs
  • ❌ Wrapper hell in DevTools
  • ❌ Prop naming collisions
  • ❌ Difficult to trace where props come from
  • ❌ Can't use inside component body

When to use HOCs:

  • Legacy codebases with class components
  • When you need to modify component props
  • When composing third-party libraries
  • When the pattern is already established

Pattern 2: Render Props

Basic Render Props with TypeScript

// Auth.tsx
interface User {
  id: string;
  name: string;
  email: string;
}

interface AuthRenderProps {
  user: User | null;
  login: (email: string, password: string) => Promise<void>;
  logout: () => void;
  isLoading: boolean;
}

interface AuthProps {
  children: (props: AuthRenderProps) => React.ReactNode;
}

export function Auth({ children }: AuthProps) {
  const [user, setUser] = useState<User | null>(null);
  const [isLoading, setIsLoading] = useState(true);

  const login = async (email: string, password: string) => {
    const user = await loginAPI(email, password);
    setUser(user);
  };

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

  useEffect(() => {
    // Check for existing session
    checkSession().then(setUser).finally(() => setIsLoading(false));
  }, []);

  return children({ user, login, logout, isLoading });
}

// Usage - full type safety!
function App() {
  return (
    <Auth>
      {({ user, login, logout, isLoading }) => {
        if (isLoading) return <div>Loading...</div>;

        if (!user) {
          return <LoginForm onLogin={login} />;
        }

        return (
          <div>
            <h1>Welcome, {user.name}</h1>
            <button onClick={logout}>Logout</button>
          </div>
        );
      }}
    </Auth>
  );
}
Enter fullscreen mode Exit fullscreen mode

Generic Render Props

// DataFetcher.tsx
interface DataFetcherRenderProps<T> {
  data: T | null;
  isLoading: boolean;
  error: Error | null;
  refetch: () => void;
}

interface DataFetcherProps<T> {
  fetchData: () => Promise<T>;
  children: (props: DataFetcherRenderProps<T>) => React.ReactNode;
}

export function DataFetcher<T>({ 
  fetchData, 
  children 
}: DataFetcherProps<T>) {
  const [data, setData] = useState<T | null>(null);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);

  const fetch = useCallback(() => {
    setIsLoading(true);
    setError(null);
    fetchData()
      .then(setData)
      .catch(setError)
      .finally(() => setIsLoading(false));
  }, [fetchData]);

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

  return children({ 
    data, 
    isLoading, 
    error, 
    refetch: fetch 
  });
}

// Usage - TypeScript infers T from fetchUsers
interface User {
  id: string;
  name: string;
}

function UserList() {
  return (
    <DataFetcher fetchData={fetchUsers}>
      {({ data, isLoading, error, refetch }) => {
        if (isLoading) return <div>Loading...</div>;
        if (error) return <div>Error: {error.message}</div>;
        if (!data) return null;

        // TypeScript knows data is User[]
        return (
          <div>
            <button onClick={refetch}>Refresh</button>
            <ul>
              {data.map(user => (
                <li key={user.id}>{user.name}</li>
              ))}
            </ul>
          </div>
        );
      }}
    </DataFetcher>
  );
}
Enter fullscreen mode Exit fullscreen mode

Render Props with Default Rendering

// Toggle.tsx
interface ToggleRenderProps {
  isOn: boolean;
  toggle: () => void;
  setOn: (value: boolean) => void;
}

interface ToggleProps {
  defaultOn?: boolean;
  children?: (props: ToggleRenderProps) => React.ReactNode;
  // Optional: provide default UI
  render?: (props: ToggleRenderProps) => React.ReactNode;
}

export function Toggle({ 
  defaultOn = false, 
  children, 
  render 
}: ToggleProps) {
  const [isOn, setIsOn] = useState(defaultOn);

  const toggle = () => setIsOn(prev => !prev);
  const setOn = (value: boolean) => setIsOn(value);

  const renderProps: ToggleRenderProps = { isOn, toggle, setOn };

  // Use render prop if provided, otherwise children, otherwise default
  if (render) return render(renderProps);
  if (children) return children(renderProps);

  // Default UI
  return (
    <button onClick={toggle}>
      {isOn ? 'ON' : 'OFF'}
    </button>
  );
}

// Usage - multiple ways
// 1. Default UI
<Toggle defaultOn={true} />

// 2. Children render prop
<Toggle>
  {({ isOn, toggle }) => (
    <label>
      <input type="checkbox" checked={isOn} onChange={toggle} />
      {isOn ? 'Enabled' : 'Disabled'}
    </label>
  )}
</Toggle>

// 3. Render prop
<Toggle
  render={({ isOn, toggle }) => (
    <div className={isOn ? 'active' : 'inactive'} onClick={toggle}>
      Click me
    </div>
  )}
/>
Enter fullscreen mode Exit fullscreen mode

Render Props Composition (The Pain)

// ❌ Callback hell
function App() {
  return (
    <Auth>
      {({ user }) => (
        <Theme>
          {({ theme }) => (
            <DataFetcher fetchData={fetchPosts}>
              {({ data: posts }) => (
                <WindowSize>
                  {({ width }) => (
                    <Dashboard 
                      user={user} 
                      theme={theme} 
                      posts={posts} 
                      width={width} 
                    />
                  )}
                </WindowSize>
              )}
            </DataFetcher>
          )}
        </Theme>
      )}
    </Auth>
  );
}
Enter fullscreen mode Exit fullscreen mode

Render Props Pros and Cons

Pros:

  • ✅ Explicit data flow
  • ✅ No prop naming collisions
  • ✅ Can customize rendering completely
  • ✅ Good TypeScript inference

Cons:

  • ❌ Callback hell with multiple render props
  • ❌ Harder to test
  • ❌ Extra components in tree
  • ❌ Can't use inside component body
  • ❌ Performance overhead (function creation)

When to use Render Props:

  • When you need complete rendering control
  • When building headless UI components
  • When the consumer needs to decide layout
  • Legacy codebases using this pattern

Pattern 3: Hooks (The Winner)

Basic Hook

// useAuth.ts
interface User {
  id: string;
  name: string;
  email: string;
}

interface UseAuthReturn {
  user: User | null;
  login: (email: string, password: string) => Promise<void>;
  logout: () => void;
  isLoading: boolean;
}

export function useAuth(): UseAuthReturn {
  const [user, setUser] = useState<User | null>(null);
  const [isLoading, setIsLoading] = useState(true);

  const login = useCallback(async (email: string, password: string) => {
    const user = await loginAPI(email, password);
    setUser(user);
  }, []);

  const logout = useCallback(() => {
    setUser(null);
  }, []);

  useEffect(() => {
    checkSession()
      .then(setUser)
      .finally(() => setIsLoading(false));
  }, []);

  return { user, login, logout, isLoading };
}

// Usage - clean and simple!
function App() {
  const { user, login, logout, isLoading } = useAuth();

  if (isLoading) return <div>Loading...</div>;

  if (!user) {
    return <LoginForm onLogin={login} />;
  }

  return (
    <div>
      <h1>Welcome, {user.name}</h1>
      <button onClick={logout}>Logout</button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Generic Hook

// useData.ts
interface UseDataOptions {
  autoFetch?: boolean;
}

interface UseDataReturn<T> {
  data: T | null;
  isLoading: boolean;
  error: Error | null;
  refetch: () => void;
}

export function useData<T>(
  fetchData: () => Promise<T>,
  options: UseDataOptions = {}
): UseDataReturn<T> {
  const { autoFetch = true } = options;
  const [data, setData] = useState<T | null>(null);
  const [isLoading, setIsLoading] = useState(autoFetch);
  const [error, setError] = useState<Error | null>(null);

  const fetch = useCallback(() => {
    setIsLoading(true);
    setError(null);
    fetchData()
      .then(setData)
      .catch(setError)
      .finally(() => setIsLoading(false));
  }, [fetchData]);

  useEffect(() => {
    if (autoFetch) {
      fetch();
    }
  }, [autoFetch, fetch]);

  return { data, isLoading, error, refetch: fetch };
}

// Usage - beautiful type inference
interface User {
  id: string;
  name: string;
}

function UserList() {
  const { data, isLoading, error, refetch } = useData(fetchUsers);

  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;
  if (!data) return null;

  // TypeScript knows data is User[]
  return (
    <div>
      <button onClick={refetch}>Refresh</button>
      <ul>
        {data.map(user => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Composing Hooks (The Dream)

// No nesting, no wrappers, just composition!
function Dashboard() {
  const { user } = useAuth();
  const { theme } = useTheme();
  const { data: posts } = useData(fetchPosts);
  const { width } = useWindowSize();

  return (
    <div className={theme}>
      <h1>Welcome, {user.name}</h1>
      <div>Window width: {width}px</div>
      <PostList posts={posts} />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Hooks Pros and Cons

Pros:

  • ✅ No wrapper components
  • ✅ Perfect composition
  • ✅ Excellent TypeScript inference
  • ✅ Testable in isolation
  • ✅ Can use conditionally (with rules)
  • ✅ Smaller bundle size
  • ✅ Better DevTools experience

Cons:

  • ❌ Rules of Hooks (can't use in loops/conditions)
  • ❌ Can't use in class components
  • ❌ Learning curve for beginners
  • ❌ Requires React 16.8+

When to use Hooks:

  • Always, if you can
  • New features and components
  • Any modern React codebase

Side-by-Side Comparison

The Same Logic: Three Ways

Scenario: Fetch and display user data with loading and error states.

// 1. HOC Version
interface WithUserDataProps {
  userData: User[];
  isLoading: boolean;
  error: Error | null;
}

function withUserData<P extends object>(
  Component: ComponentType<P & WithUserDataProps>
): ComponentType<P> {
  return function UserDataComponent(props: P) {
    const [userData, setUserData] = useState<User[]>([]);
    const [isLoading, setIsLoading] = useState(true);
    const [error, setError] = useState<Error | null>(null);

    useEffect(() => {
      fetchUsers()
        .then(setUserData)
        .catch(setError)
        .finally(() => setIsLoading(false));
    }, []);

    return (
      <Component 
        {...props} 
        userData={userData} 
        isLoading={isLoading} 
        error={error} 
      />
    );
  };
}

// Usage
function UserList({ userData, isLoading, error }: WithUserDataProps) {
  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;
  return <ul>{userData.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}

export default withUserData(UserList);


// 2. Render Props Version
interface UserDataRenderProps {
  userData: User[];
  isLoading: boolean;
  error: Error | null;
}

function UserDataProvider({ 
  children 
}: { 
  children: (props: UserDataRenderProps) => React.ReactNode 
}) {
  const [userData, setUserData] = useState<User[]>([]);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    fetchUsers()
      .then(setUserData)
      .catch(setError)
      .finally(() => setIsLoading(false));
  }, []);

  return children({ userData, isLoading, error });
}

// Usage
function UserList() {
  return (
    <UserDataProvider>
      {({ userData, isLoading, error }) => {
        if (isLoading) return <div>Loading...</div>;
        if (error) return <div>Error: {error.message}</div>;
        return <ul>{userData.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
      }}
    </UserDataProvider>
  );
}


// 3. Hook Version
interface UseUserDataReturn {
  userData: User[];
  isLoading: boolean;
  error: Error | null;
}

function useUserData(): UseUserDataReturn {
  const [userData, setUserData] = useState<User[]>([]);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    fetchUsers()
      .then(setUserData)
      .catch(setError)
      .finally(() => setIsLoading(false));
  }, []);

  return { userData, isLoading, error };
}

// Usage
function UserList() {
  const { userData, isLoading, error } = useUserData();

  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;
  return <ul>{userData.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}
Enter fullscreen mode Exit fullscreen mode

TypeScript Comparison

Feature HOC Render Props Hooks
Type Inference 🟡 Breaks with nesting 🟢 Excellent 🟢 Excellent
Prop Typing 🔴 Complex generics 🟢 Straightforward 🟢 Straightforward
Return Type 🟡 Needs explicit typing 🟢 Inferred 🟢 Inferred
Generic Support 🟡 Complex 🟢 Good 🟢 Excellent
Ref Forwarding 🔴 Difficult N/A 🟢 Easy

Migration Strategies

HOC to Hook

// Before: HOC
interface WithAuthProps {
  user: User;
  logout: () => void;
}

function withAuth<P extends object>(
  Component: ComponentType<P & WithAuthProps>
) {
  return function AuthComponent(props: P) {
    const user = useAuth();
    const logout = useLogout();
    return <Component {...props} user={user} logout={logout} />;
  };
}

export default withAuth(Dashboard);

// After: Hook
function Dashboard() {
  const user = useAuth();
  const logout = useLogout();

  // Component logic
}
Enter fullscreen mode Exit fullscreen mode

Render Props to Hook

// Before: Render Props
<DataFetcher fetchData={fetchUsers}>
  {({ data, isLoading, error }) => (
    <UserList data={data} isLoading={isLoading} error={error} />
  )}
</DataFetcher>

// After: Hook
function UserList() {
  const { data, isLoading, error } = useData(fetchUsers);
  // Component logic
}
Enter fullscreen mode Exit fullscreen mode

Gradual Migration Strategy

// Step 1: Keep HOC/Render Prop, extract logic to hook
function useUserData() {
  // Logic extracted here
}

function withUserData<P>(Component: ComponentType<P & WithUserDataProps>) {
  return function(props: P) {
    const userData = useUserData(); // Use the hook internally
    return <Component {...props} {...userData} />;
  };
}

// Step 2: Migrate components to use hook directly
// Step 3: Remove HOC once all components migrated
Enter fullscreen mode Exit fullscreen mode

Hybrid Approaches

Hook + Render Prop (Headless UI Pattern)

// Provide both APIs!
interface SelectRenderProps<T> {
  value: T | null;
  isOpen: boolean;
  toggle: () => void;
  select: (value: T) => void;
}

interface SelectProps<T> {
  value: T | null;
  onChange: (value: T) => void;
  children: (props: SelectRenderProps<T>) => React.ReactNode;
}

// Hook for internal logic
export function useSelect<T>(
  value: T | null,
  onChange: (value: T) => void
) {
  const [isOpen, setIsOpen] = useState(false);

  const toggle = () => setIsOpen(prev => !prev);
  const select = (value: T) => {
    onChange(value);
    setIsOpen(false);
  };

  return { value, isOpen, toggle, select };
}

// Component using render prop
export function Select<T>({ value, onChange, children }: SelectProps<T>) {
  const selectProps = useSelect(value, onChange);
  return children(selectProps);
}

// Usage - consumers choose!

// 1. Use the component with render prop
<Select value={value} onChange={setValue}>
  {({ isOpen, toggle, select }) => (
    <div>
      <button onClick={toggle}>Select</button>
      {isOpen && <Options onSelect={select} />}
    </div>
  )}
</Select>

// 2. Or use the hook directly
function CustomSelect() {
  const { isOpen, toggle, select } = useSelect(value, setValue);
  return (
    <div>
      <button onClick={toggle}>Select</button>
      {isOpen && <Options onSelect={select} />}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Hook + HOC for Legacy Support

// Modern hook
export function useFeatureFlags() {
  const [flags, setFlags] = useState<Record<string, boolean>>({});

  useEffect(() => {
    fetchFeatureFlags().then(setFlags);
  }, []);

  return flags;
}

// Legacy HOC wrapper for class components
export function withFeatureFlags<P extends object>(
  Component: ComponentType<P & { flags: Record<string, boolean> }>
): ComponentType<P> {
  return function FeatureFlagsComponent(props: P) {
    const flags = useFeatureFlags();
    return <Component {...props} flags={flags} />;
  };
}

// Modern components use hook
function ModernComponent() {
  const flags = useFeatureFlags();
  // ...
}

// Legacy class components use HOC
class LegacyComponent extends React.Component<{ flags: Record<string, boolean> }> {
  render() {
    const { flags } = this.props;
    // ...
  }
}

export default withFeatureFlags(LegacyComponent);
Enter fullscreen mode Exit fullscreen mode

Real-World Decision Tree

Do you have class components?
├─ Yes → HOC or Render Props
└─ No → Hooks

Do you need to modify component props?
├─ Yes → HOC
└─ No → Hooks or Render Props

Do consumers need rendering control?
├─ Yes → Render Props or Hooks + Render Props
└─ No → Hooks

Is this a new feature?
├─ Yes → Hooks
└─ No → Match existing pattern

Do you need to compose multiple concerns?
├─ Yes → Hooks (much cleaner)
└─ No → Any pattern works

Are you building a library?
├─ Yes → Hooks + Render Props (both APIs)
└─ No → Hooks
Enter fullscreen mode Exit fullscreen mode

Performance Comparison

Bundle Size Impact

// HOC - adds wrapper component
// Bundle: +500 bytes per HOC

// Render Props - adds component + render function
// Bundle: +600 bytes per render prop

// Hooks - just a function
// Bundle: +200 bytes per hook
Enter fullscreen mode Exit fullscreen mode

Runtime Performance

// HOC
// - Creates wrapper components (extra tree depth)
// - Props spreading overhead
// - React.memo needed to prevent re-renders

// Render Props  
// - Creates components (extra tree depth)
// - Function creation on every render
// - Callback overhead

// Hooks
// - No extra components
// - Optimized by React
// - useMemo/useCallback when needed
Enter fullscreen mode Exit fullscreen mode

Best Practices by Pattern

HOC Best Practices

// ✅ Forward refs
export function withAuth<P extends object>(
  Component: ComponentType<P>
) {
  return forwardRef<any, P>((props, ref) => {
    const auth = useAuth();
    return <Component ref={ref} {...props} {...auth} />;
  });
}

// ✅ Hoist non-react statics
import hoistNonReactStatics from 'hoist-non-react-statics';

export function withAuth<P>(Component: ComponentType<P>) {
  const WithAuth = (props: P) => {
    // ...
  };

  return hoistNonReactStatics(WithAuth, Component);
}

// ✅ Display names for DevTools
WithAuth.displayName = `WithAuth(${Component.displayName || Component.name})`;
Enter fullscreen mode Exit fullscreen mode

Render Props Best Practices

// ✅ Memoize render functions
function App() {
  const renderUsers = useCallback(({ users }: { users: User[] }) => (
    <UserList users={users} />
  ), []);

  return <UserDataProvider>{renderUsers}</UserDataProvider>;
}

// ✅ Provide default rendering
interface ToggleProps {
  children?: (props: ToggleRenderProps) => ReactNode;
  render?: (props: ToggleRenderProps) => ReactNode;
}

export function Toggle({ children, render }: ToggleProps) {
  const props = useToggle();

  if (render) return render(props);
  if (children) return children(props);

  return <DefaultToggleUI {...props} />;
}
Enter fullscreen mode Exit fullscreen mode

Hook Best Practices

// ✅ Return objects, not arrays (usually)
// Better
function useAuth() {
  return { user, login, logout };
}

// Unless mimicking useState
function useToggle(initialValue: boolean) {
  return [value, toggle] as const;
}

// ✅ Prefix with 'use'
function useWindowSize() { /* ... */ }

// ✅ Extract to custom hooks early
function MyComponent() {
  // ❌ Complex logic in component
  const [users, setUsers] = useState([]);
  useEffect(() => { /* ... */ }, []);

  // ✅ Extract to hook
  const users = useUsers();
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

The composition pattern landscape has evolved:

HOCs (2015-2018):

  • Legacy pattern, still useful for class components
  • TypeScript support is complex
  • Use when you must, not by default

Render Props (2017-2019):

  • Transitional pattern
  • Good TypeScript support
  • Use for headless UI libraries
  • Avoid in new applications

Hooks (2019-Present):

  • Modern standard
  • Excellent TypeScript support
  • Use for everything new
  • Migrate to hooks when refactoring

The decision matrix:

  • New code: Hooks
  • Class components: HOC
  • Headless UI: Hooks + Render Props
  • Library API: Hooks + optional Render Props
  • Legacy: Match existing pattern

TypeScript makes hooks even better than in JavaScript. Type inference works perfectly, composition is clean, and there's no wrapper hell.

If you're starting fresh: use hooks. If you're maintaining legacy code: migrate to hooks. The era of HOCs and Render Props is over.

Hooks won.


Which pattern do you use most? Share your migration stories in the comments!

Top comments (0)