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>
);
}
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>
);
}
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>
);
}
);
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>
);
}
);
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);
Or complex conditions directly in JSX:
{user.isAdmin && permissions.includes('edit') && (
<Button>Edit</Button>
)}
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)