DEV Community

Talisson
Talisson

Posted on

UI Logic Should Live in Hooks, Not in JSX

When building React components, it's tempting to write logic directly inside JSX. After all, JSX looks like HTML, and sprinkling some conditional rendering or small calculations inline feels natural. But as your UI grows, this approach quickly leads to cluttered components that are hard to read, reuse, and maintain.

There's a better way: Move UI logic into custom hooks.


What Do We Mean by "UI Logic"?

UI logic refers to any behavior that controls how something is displayed or interacted with in the UI. This includes things like:

  • Conditional rendering (show/hide elements based on state)
  • Derived state (e.g., isSelected = currentId === item.id)
  • Event handling logic
  • Accessibility behaviors (e.g., focus management)
  • Animation triggers and states

These are not business rules or API calls — they're decisions that shape the UI experience.


The Problem with JSX-Centric Logic

Let's look at a typical example:

export function UserCard({ user, selectedId, onSelect }: Props) {
  return (
    <div
      className={`user-card ${selectedId === user.id ? 'selected' : ''}`}
      onClick={() => onSelect(user.id)}
    >
      {user.isAdmin && <span className="badge">Admin</span>}
      <p>{user.name}</p>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

This works fine — until it doesn't. What happens when:

  • The "selected" logic becomes more complex?
  • You need additional accessibility handling (e.g., ARIA attributes)?
  • The badge rendering requires more rules?
  • You want to reuse this logic elsewhere?

JSX becomes noisy, making the component harder to scan. Every new requirement increases cognitive load.


Why Hooks Are the Right Place for UI Logic

Custom hooks provide a clean separation between behavior and structure.

Let's refactor the example using a hook:

function useUserCard(user, selectedId, onSelect) {
  const isSelected = selectedId === user.id;

  const handleClick = () => {
    onSelect(user.id);
  };

  const showBadge = user.isAdmin;

  return { isSelected, handleClick, showBadge };
}

export function UserCard({ user, selectedId, onSelect }: Props) {
  const { isSelected, handleClick, showBadge } = useUserCard(user, selectedId, onSelect);

  return (
    <div className={`user-card ${isSelected ? 'selected' : ''}`} onClick={handleClick}>
      {showBadge && <span className="badge">Admin</span>}
      <p>{user.name}</p>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Notice how:

  • JSX now only describes the structure of the UI.
  • Logic is testable in isolation.
  • You can reuse useUserCard elsewhere.

Real-World Example: NavigationItem Refactor

Let's take a real-world example of a complex component and refactor it.

Before: All-in-One Component

export const NavigationItem = forwardRef<HTMLButtonElement, NavigationItemProps>(
  ({ label, icon, isActive = false, ...props }, forwardedRef) => {
    const [open, setOpen] = useState(false);
    const [isFocused, setIsFocused] = useState(false);
    const [isOverflowing, setIsOverflowing] = useState(false);
    const contentRef = useRef<HTMLSpanElement | null>(null);

    const toggleOpen = () => setOpen((prev) => !prev);

    const handleContextMenu = (e) => { /* logic */ };
    const handleKeyDown = (e) => { /* logic */ };
    const handleClick = (e) => { /* logic */ };

    useEffect(() => { /* measure overflow */ }, [label]);

    const renderedIcon = isActive || isFocused ? cloneElement(icon, { color: 'activeColor' }) : icon;

    return (
      <Button {...props} ref={forwardedRef}>
        {/* structure mixed with logic */}
      </Button>
    );
  }
);
Enter fullscreen mode Exit fullscreen mode

Problems:

  • Logic and rendering tightly coupled.
  • Hard to reuse logic (e.g., overflow detection, toggle state).
  • JSX cluttered with inline handlers.

After: Hooks Composition Pattern

I broke the logic into small focused hooks:

  • useControlledToggle → Handles open state management.
  • useFocusState → Tracks focus/blur state.
  • useTextOverflow → Detects overflow content.
  • useActiveIcon → Derives icon state based on activity/focus.
export const NavigationItem = forwardRef<HTMLButtonElement, NavigationItemProps>(
  ({ label, icon, isActive = false, onToggleDropdown, ...props }, forwardedRef) => {
    const [open, setOpen, toggleOpen] = useControlledToggle(undefined, onToggleDropdown);
    const { isFocused, onFocus, onBlur } = useFocusState();
    const contentRef = useRef<HTMLSpanElement | null>(null);
    const isOverflowing = useTextOverflow(contentRef, [label]);
    const renderedIcon = useActiveIcon(icon, isActive, isFocused);

    const handleContextMenu = (e) => { /* simplified */ };
    const handleKeyDown = (e) => { /* simplified */ };
    const handleClick = (e) => { /* simplified */ };

    return (
      <NavigationDropdown isOpen={open} onOpenChange={setOpen}>
        <Tooltip content={isOverflowing ? label : undefined}>
          <Button
            ref={forwardedRef}
            onClick={handleClick}
            onKeyDown={handleKeyDown}
            onContextMenu={handleContextMenu}
            onFocus={onFocus}
            onBlur={onBlur}
            aria-current={isActive ? 'page' : undefined}
            variant={isActive ? 'active' : 'standard'}
            {...props}
          >
            <InnerText ref={contentRef}>{renderedIcon}{!isOverflowing && label}</InnerText>
            <VerticalDotsIcon data-visible={isActive} />
          </Button>
        </Tooltip>
      </NavigationDropdown>
    );
  }
);
Enter fullscreen mode Exit fullscreen mode

Division of Responsibilities:

Concern Hook / Responsibility
Dropdown Open State useControlledToggle
Focus State Management useFocusState
Overflow Detection useTextOverflow
Icon State Logic useActiveIcon
Rendering Structure NavigationItem Component JSX

Benefits of Hooks Composition:

  • Encapsulation: Each concern is isolated in its own hook.
  • Reusability: Hooks like useTextOverflow can be reused in tabs, breadcrumbs, etc.
  • Simplified JSX: JSX now reads as a structure blueprint, free of logic noise.
  • Testability: Hooks can be tested in isolation without rendering components.
  • Scalability: Adding new behaviors (animations, ARIA patterns) won’t bloat the component.

Avoiding Over-Engineering: When Not to Abstract Hooks

While hooks are a powerful tool for managing UI logic, over-abstracting too early can hurt more than help. Abstraction should serve clarity, not complexity.

Here’s when it’s better to keep logic inside the component:

  • If it’s only a few lines and unlikely to be reused, it’s fine to leave it inline.
  • Avoid moving visual structure (markup decisions) into hooks — hooks should handle behavior and state, not render JSX.
  • Prioritize readability: Don’t split trivial logic into many micro-hooks if it makes the component harder to follow.

A hook’s purpose is to clarify responsibilities, not just to “move code out of the component.” Use hooks to:

  • Group related behaviors.
  • Isolate side effects.
  • Derive complex UI state.

But don’t feel pressured to abstract everything prematurely. A balanced approach wins.

A Rule of Thumb

Whenever you find yourself writing derived values like this:

const isSelected = selectedId === item.id;
const visibleItems = items.filter(item => item.visible);
Enter fullscreen mode Exit fullscreen mode

Or complex conditions directly in JSX:

{user.isAdmin && permissions.includes('edit') && (
  <Button>Edit</Button>
)}
Enter fullscreen mode Exit fullscreen mode

Ask yourself:

"Is this logic shaping UI behavior? Would it improve readability and reuse if I moved it to a hook?"

If the answer is yes, abstract it. If it’s trivial and specific to this component, keep it inline.

Hooks should clarify, not overcomplicate your codebase.

Conclusion

Great UIs are not just about visuals; they’re about maintainable, scalable architecture. By moving UI logic into hooks, you create components that are easier to read, extend, and reuse. Your JSX becomes declarative and your logic composable.

Stop putting logic in your JSX. Embrace hooks as the home for UI behavior.


What do you think? Are you team logic-in-hooks or do you still prefer inline logic? Let’s discuss in the comments!

Top comments (0)