Skip to content

feat(react-utils): preliminary component hooks - #812

Open
Dashice wants to merge 2 commits into
mainfrom
feat/react-utils-preliminary-component-hooks
Open

feat(react-utils): preliminary component hooks#812
Dashice wants to merge 2 commits into
mainfrom
feat/react-utils-preliminary-component-hooks

Conversation

@Dashice

@Dashice Dashice commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Philosophy

This PR showcases the philosophy behind cooks (component-hooks) and includes native React hook to cook conversions and one custom hook conversion. This is done to showcase the various methodologies which can appear when creating future cooks.

Anatomy

<UseState initialState={false}>
  {([isIntersecting, setIsIntersecting]) => (
    <UseIntersectionObserver
      callback={(entry) => setIsIntersecting(entry.isIntersecting)}
    >
      <UseSticky when={isIntersecting}>
        <div
          className={cn(
            'top-0 sticky duration-300 bg-transparent data-stuck:bg-white',
            isIntersecting ? 'opacity-100' : 'opacity-0'
          )}
        />
      </UseSticky>
    </UseIntersectionObserver>
  )}
</UseState>
  • UseState exposes a state and setter via render function.
  • UseIntersectionObserver executes callback when <div /> intersects the viewport and updates state of UseState.
  • UseSticky doesn't require render function, and attaches data-stuck to <div /> from within.
  • No useRef is used in this context.
  • UseSticky cook does not require ref, even though hook useSticky does as it is created internally.
  • UseSticky logic is disabled when <div /> is not intersecting the viewport.
  • All cooks act on the same underlying <div /> simultaneously.
  • When state changes, only this branch of JSX re-renders.

Purpose

Component hooks allow hook-logic to be conditional, initialized in JSX-loops, and re-render only a portion of JSX instead of entire component. This avoids the necessity to create abstract components that encapsulate frequent state updates. They can also allow developers to circumvent needing "use client"; in certain contexts.

Ruleset

Cooks require specific wiring to be viable; and developers should adhere to a standard in order to deploy cooks at-scale. They are not created equal, and there are three general branching approaches. Some universal rules exist between cooks:

  1. All cooks must only allow one child
    • A fragment, element, component, false, null or undefined.
    • A render function that return a fragment, element, component, false, null or undefined.
  2. Type API must accept ref
    • Always convert passed ref to RefObject with useRefObject(ref).
    • If ref is provided, or cook requires a ref attached on an element, and child is Fragment; throw error.
    • If ref is provided, or cook requires a ref attached on an element; forward it using cloneElement.
    • If ref is not provided, and cook doesn't require a ref attached on an element; do not forward it.
  3. DOM-compatible props and child props should be merged and forwarded onto child with cloneElement
    • This does not apply if child is Fragment. In such scenario, render child without forwarding props.
    • This does not apply if child is false, null, undefined. In such scenario, render child without forwarding props.
    • When merging props: { ref, ...cookProps, ...cookComputedProps ...childProps }, ensures that attributes explicitly set further down DOM tree overwrite attributes forwarded down from above.
  4. If child already has ref set before cook can forward its own ref, an error should be thrown, prompting to move ref onto the cook
  5. Cooks should never return anything other than children or cloneElement(children, ...)
    • No Fragment.
    • No polymorphic component
    • No wrapping element.

Design Approach

When creating a cook, a developer must choose between three general render approaches; Element, Render Function, Hybrid. When to use which is outlined below.

Element

<UseElement>
  <div>...</div>
</UseElement>

If the underlying hook does not return anything meaningful that is worth exposing as a variable; set children to type ReactElement. Useful for hooks that do not return anything, adding event listeners or operating on a ref passively.

Render Function

<UseRF>
  {(props) => (
    <div>...</div>
  )}
</UseRF>

If the underlying hook returns something meaningful, and cannot be represented in the DOM as an attribute; set children to (prop: X) => ReactElement. Useful for hooks that return complex shapes, multiple values, callbacks, etc.

Hybrid

<UseHybrid>
  <div>...</div>
</UseHybrid>

<UseHybrid>
  {(props) => (
    <div>...</div>
  )}
</UseHybrid>

If the underlying hook returns something meaningful, but can be simplified, or most commonly only a boolean or string union is consumed for styling purposes; injecting a data-* attribute from within the cook is possible like so: cloneElement(childElement, {...props, 'data-attr': value });. This allows consumers to either pass a render function or an element as a child, in other words; a hybrid approach.

In this PR, all three methods are used: UseEffect (Element), UseState (Render function), UseRTL (Hybrid).

Guiding Consumer Adherence to Ruleset

Have a look at the following code, that on-paper looks reasonable:

const ref = useRef(null);

<UseCook>
  <div ref={ref}>...</div>
  <div>...</div>
</UseCook>

Adhering to the ruleset above; a TS error will indicate that UseCook may only have one child, guiding the user to adjust their code to:

const ref = useRef(null);

<>
  <UseCook>
    <div ref={ref}>...</div>
  </UseCook>

  <div>...</div>
</>

Next, if the user saves their file, the following error would be thrown in a browser environment:

<UseCook /> child cannot have a "ref" set. Set "ref" on <UseCook /> instead.

If this was not enforced, either the ref created inside the cook, or the ref provided by the user would have no effect. By guiding the user to move ref={ref} onto the <UseCook ref={ref} />, the passed ref will be used as the base; instead of the ref created inside the cooks internal code. In situations where multiple cooks act on a singular element, this will ensure that the consumer will be able to perform DOM manipulations on their desired element, whilst simultaneously allowing all cooks to perform their own actions on the same element.

This leads to the correct code:

const ref = useRef(null);

<>
  <UseCook ref={ref}>
    <div>...</div>
  </UseCook>

  <div>...</div>
</>

Fragments

As cooks work with ReactElement (a super of Fragment), Fragment's are allowed on cooks that do not require a ref to be attached on its child.

<UseState>
  {([value, setValue]) => (
    <>
      <div>...</div>
      <div>...</div>
      <div>...</div>
    </>
  )}
</UseState>

Ensure that any cook that requires a ref to be attached on an element throws an error in the cook chain.

<UseDragScroll axis="x" inertia>
  <UseState>
    {([value, setValue]) => (
      <>
        <div>...</div>
        <div>...</div>
        <div>...</div>
      </>
    )}
  </UseState>
</UseDragScroll>

This should error, despite Fragment being a ReactElement. UseDragScroll must attach itself on an element in order to function. Fragment's are singular elements, but they are transient and what remains in DOM are three <div /> elements, which UseDragScroll will not be able to determine which to attach itself to.

@Dashice Dashice self-assigned this Aug 3, 2026
@changeset-bot

changeset-bot Bot commented Aug 3, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 548e073

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@noaignite/react-utils Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercel Bot commented Aug 3, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
docs Ready Ready Preview Aug 31, 2026 11:44am

Request Review

@codecov

codecov Bot commented Aug 3, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.74468% with 4 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
packages/react-utils/src/useId.component.ts 94.44% 0 Missing and 1 partial ⚠️
packages/react-utils/src/useRTL.component.ts 95.45% 0 Missing and 1 partial ⚠️
packages/react-utils/src/useRef.component.ts 94.11% 0 Missing and 1 partial ⚠️
packages/react-utils/src/useState.component.ts 94.11% 0 Missing and 1 partial ⚠️
@@                    Coverage Diff                     @@
##           feature/use-ref-object     #812      +/-   ##
==========================================================
+ Coverage                   71.76%   73.70%   +1.94%     
==========================================================
  Files                          68       75       +7     
  Lines                        1066     1160      +94     
  Branches                      268      289      +21     
==========================================================
+ Hits                          765      855      +90     
  Misses                        237      237              
- Partials                       64       68       +4     
Files with missing lines Coverage Δ
packages/react-utils/src/isReactElement.ts 100.00% <100.00%> (ø)
packages/react-utils/src/isReactFragment.ts 100.00% <100.00%> (ø)
packages/react-utils/src/useEffect.component.ts 100.00% <100.00%> (ø)
packages/react-utils/src/useId.component.ts 94.44% <94.44%> (ø)
packages/react-utils/src/useRTL.component.ts 95.45% <95.45%> (ø)
packages/react-utils/src/useRef.component.ts 94.11% <94.11%> (ø)
packages/react-utils/src/useState.component.ts 94.11% <94.11%> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@Dashice
Dashice force-pushed the feat/react-utils-preliminary-component-hooks branch from 621a5ab to e0f8bd4 Compare August 4, 2026 13:03
@maeertin

maeertin commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Such a nice read, let's aim to get this in 🌟

@Dashice
Dashice force-pushed the feat/react-utils-preliminary-component-hooks branch from e0f8bd4 to a48a1ac Compare August 5, 2026 13:10
@Dashice
Dashice force-pushed the feat/react-utils-preliminary-component-hooks branch from a48a1ac to 6fd64ec Compare August 6, 2026 13:49
@Dashice
Dashice marked this pull request as ready for review August 6, 2026 13:50
@Dashice
Dashice changed the base branch from main to feature/use-ref-object August 31, 2026 10:17
@Dashice
Dashice force-pushed the feat/react-utils-preliminary-component-hooks branch from 6fd64ec to 548e073 Compare August 31, 2026 11:43
@Dashice
Dashice requested review from maeertin and removed request for maeertin August 31, 2026 11:44
@Dashice
Dashice force-pushed the feature/use-ref-object branch 2 times, most recently from f6f3aec to 2a23b84 Compare August 31, 2026 12:46
Base automatically changed from feature/use-ref-object to main August 31, 2026 12:54

@adamsoderstrom adamsoderstrom left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks great, @Dashice!
Awesome read! 🌟

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants