Skip to content

Conversation

italoalmeida0
Copy link

@italoalmeida0 italoalmeida0 commented Aug 7, 2025

First of all, thank you for making Solid - I really love it and use it in my projects. It's truly the framework that makes sense.

I've been using createRef in my SolidStart projects and I love it. I use it to create components etc, it's very productive and performatic.

Note: I used AI to help structure and improve the writing (English isn't my first language), but all ideas, code, and examples are mine from real production use.


The Problem: Wrappers Without Purpose

Looking at the current primitives landscape, I noticed a concerning pattern. Many primitives are essentially wrappers around APIs that already work perfectly:

  • createTimer (375B) - wraps setTimeout/setInterval
  • createGeolocation (465B) - wraps navigator.geolocation
  • createScrollPosition (745B) - wraps element.scrollTop
  • createResizeObserver (611B) - wraps native ResizeObserver
  • createEventListener (372B) - wraps addEventListener
  • createFullscreen (411B) - wraps requestFullscreen()
  • createClipboard (499B) - wraps navigator.clipboard
  • createPageVisibility (406B) - wraps document.visibilityState

These don't solve real problems - they just add overhead to APIs that already work.

The philosophy says "Wrap base level Browser APIs" but I believe this should mean "solve real problems that native APIs can't handle elegantly" - not create abstractions for the sake of it.


The Real Problem: SSR + Element Existence

Here's a problem that actually needs solving:

// ❌ The reality of SSR-safe DOM code today
function MyComponent() {
  let ref: HTMLElement;
  
  createEffect(() => {
    if (typeof window === 'undefined') return; // SSR check
    if (!ref) return; // Element check
    
    ref.addEventListener('click', handler);
    // More defensive programming...
  });
  
  return <div ref={ref} />;
}

This is the real problem: defensive programming everywhere, SSR checks, element existence checks, manual cleanup, hydration mismatches.


The createRef Solution

// ✅ Clean, guaranteed execution
function MyComponent() {
  const { setter: ref, addEventOnChange } = createRef<HTMLDivElement>();
  
  // Guaranteed: only runs when element exists + client-side
  addEventOnChange(el => {
    el.addEventListener('click', handler);
    return () => el.removeEventListener('click', handler);
  }, 'click-handler');
  
  // Multiple named listeners with automatic cleanup
  addEventOnChange(el => {
    const observer = new ResizeObserver(handleResize);
    observer.observe(el);
    return () => observer.disconnect();
  }, 'resize-observer');
  
  return <div ref={ref} />;
}

This is not just another wrapper - it's a formal contract that guarantees your DOM code only runs when elements actually exist, client-side, with automatic cleanup.


Why This Changes Everything

🛡️ Formal Contract, Not Just Convenience

addEventOnChange isn't reactive - it's a guarantee. Your callback only runs when the element definitively exists. No checks needed.

🚫 SSR-Safe by Design

Client-only execution eliminates hydration mismatches. No more typeof window !== 'undefined' checks.

🎯 Non-Reactive Explicit Control

Unlike signals, you have explicit control. Call element() anywhere - it won't trigger side effects unless you explicitly add them.

🧹 Automatic Cleanup

Return functions from callbacks for safe teardown. Everything gets cleaned up automatically.


Foundation for Better Primitives

createRef isn't about replacing existing primitives - it's about giving them a bulletproof foundation. Every primitive solving SSR safety from scratch creates unnecessary overhead and complexity.

Enhanced Event Listeners

// ✅ createEventListener built on createRef foundation
function createEventListener(type, handler, options, existingRef?) {
  const ref = existingRef || createRef();
  
  ref.addEventOnChange(el => {
    el.addEventListener(type, handler, options);
    return () => el.removeEventListener(type, handler, options);
  }, `event-${type}`);
  
  return ref.setter;
}

// Zero overhead when combining multiple listeners
const ref = createRef();
createEventListener('click', handleClick, {}, ref);
createEventListener('scroll', handleScroll, {}, ref);
createEventListener('resize', handleResize, {}, ref);

Shared Ref Architecture

// ✅ Multiple primitives, one ref, zero duplication
const { setter: sharedRef, addEventOnChange } = createRef<HTMLDivElement>();

// Each primitive adds its own concern
const scroll = createScrollPosition(sharedRef);
const resize = createResizeObserver(sharedRef);
const intersection = createIntersectionObserver(sharedRef);

// Custom logic shares the same guarantee
addEventOnChange(el => {
  // Your app-specific DOM code here
  setupCustomBehavior(el);
}, 'custom');

return <div ref={sharedRef} />;

Benefits of Shared Foundation

🛡️ Eliminates SSR Boilerplate
No more onMount() wrappers in every primitive. The foundation handles element existence.

⚡ Zero Overhead Composition
Multiple primitives share one ref system instead of each creating their own.

🎯 Consistent API
All primitives follow the same "element existence guarantee" pattern.

🔧 Extensible
Developers can add custom logic alongside existing primitives using the same safe foundation.

Migration Path

Existing primitives keep their APIs, but internally use createRef:

// ✅ Backward compatible createScrollPosition
function createScrollPosition(target?) {
  if (target && target.addEventOnChange) {
    // New: receives createRef setter
    const [position, setPosition] = createSignal({ x: 0, y: 0 });
    target.addEventOnChange(el => {
      const handler = () => setPosition({ x: el.scrollLeft, y: el.scrollTop });
      el.addEventListener('scroll', handler);
      return () => el.removeEventListener('scroll', handler);
    }, 'scroll-position');
    return position;
  } else {
    // Legacy: creates its own ref system (deprecated)
    const { setter, addEventOnChange } = createRef();
    // ... rest of implementation
    return { setter, position };
  }
}

The result: Better DX, zero breaking changes, shared robust foundation.


Real Example: Complex Orchestration That Makes Sense

export const createPagedScroll = () => {
 const refPrevious = createRef();
 const refPagedScroll = createRef();
 const refNext = createRef();

 const [showPrevious, setShowPrevious] = createSignal(false);
 const [showNext, setShowNext] = createSignal(false);

 let timeoutId: NodeJS.Timeout;
 let resizeObserver: ResizeObserver | null = null;
 let mutationObserver: MutationObserver | null = null;

 const checkVisibility = (arg: HTMLElement | Event) => {
   clearTimeout(timeoutId);

   timeoutId = setTimeout(() => {
     // 1. Determine the element to use.
     const pagedScroll = arg instanceof Event ? (arg.target as HTMLElement) : arg;

     if (!pagedScroll || pagedScroll.children.length === 0) return;

     const pagedScrollRect = pagedScroll.getBoundingClientRect();

     // Check first element
     const firstElement = pagedScroll.firstElementChild as HTMLElement;
     const firstRect = firstElement.getBoundingClientRect();
     const isFirstVisible = firstRect.left >= pagedScrollRect.left;

     // Check last element
     const lastElement = pagedScroll.lastElementChild as HTMLElement;
     const lastRect = lastElement.getBoundingClientRect();
     const isLastVisible = lastRect.right <= pagedScrollRect.right;

     setShowPrevious(!isFirstVisible);
     setShowNext(!isLastVisible);
   }, 50);
 };

 // Setup all observers when element is set
 refPagedScroll.addEventOnChange((pagedScroll) => {
   // Initial check
   checkVisibility(pagedScroll);

   // Setup scroll listener
   pagedScroll.addEventListener('scroll', checkVisibility);

   // Setup ResizeObserver
   resizeObserver = new ResizeObserver(() => checkVisibility(pagedScroll));
   resizeObserver.observe(pagedScroll);

   // Setup MutationObserver
   mutationObserver = new MutationObserver(() => checkVisibility(pagedScroll));
   mutationObserver.observe(pagedScroll, {
     childList: true,
     subtree: true,
   });

   return () => {
     clearTimeout(timeoutId);

     pagedScroll.removeEventListener('scroll', checkVisibility);

     resizeObserver?.disconnect();
     mutationObserver?.disconnect();

     resizeObserver = null;
     mutationObserver = null;
   };
 }, 'paged-scroll-body');

 const next = () => {
   const pagedScroll = refPagedScroll.element();
   if (pagedScroll) scrollToDirection(pagedScroll, true);
 };

 const previous = () => {
   const pagedScroll = refPagedScroll.element();
   if (pagedScroll) scrollToDirection(pagedScroll, false);
 };

 refPrevious.addEventOnChange((btn) => {
   btn.addEventListener('click', previous);
   return () => btn.removeEventListener('click', previous);
 }, 'paged-scroll-previous');

 refNext.addEventOnChange((btn) => {
   btn.addEventListener('click', next);
   return () => btn.removeEventListener('click', next);
 }, 'paged-scroll-next');

 const cleanup = () => {
   refPrevious.cleanup();
   refPagedScroll.cleanup();
   refNext.cleanup();
 };

 const BtnPrevious = (props) => (
   <Show when={showPrevious()}>
     <Button
       {...props}
       ref={RefRef(props.ref, refPrevious.setter)}
     />
   </Show>
 );

 const PagedScroll = (props) => (
   <div {...props} ref={RefRef(props.ref, refPagedScroll.setter)} />
 );

 const BtnNext = (props) => (
   <Show when={showNext()}>
     <Button
       {...props}
       ref={RefRef(props.ref, refNext.setter)}
     />
   </Show>
 );

 onCleanup(() => {
   cleanup();
 });

 return {
   BtnPrevious,
   PagedScroll,
   BtnNext,
   cleanup,
   // Expose refs for advanced usage
   refs: {
     previous: refPrevious,
     pagedScroll: refPagedScroll,
     next: refNext
   }
 };
};

This is valuable because it:

  • Coordinates 3 elements with complex logic
  • Handles scroll + resize + mutation observers
  • Provides both high-level components AND low-level ref access
  • Uses createRef as foundation for guaranteed execution
  • Has no simple native equivalent

Compare:

  • createScrollPosition = element.scrollTop (trivial wrapper)
  • createPagedScroll = complex orchestration solving real UX problem

API Surface

Method Description
setter JSX callback-ref (<div ref={setter} />).
element() Returns current element (non-reactive).
addEventOnChange(cb, key?, once?) Executes cb when ref mounts/changes (explicit control).
removeEventOnChange(key) Removes a named listener.
cleanup() Clears state (auto-called on dispose).
RefRef(a, b) Combines two refs safely.

The Bottom Line

Most current primitives are wrappers without purpose. They add overhead to APIs that already work perfectly.

createRef is different. It's the formal contract that solves the fundamental problem of SSR-safe, guaranteed DOM interactions.

It's not just another primitive - it's the foundation that other primitives should build on.

  • Real problem: SSR safety + element existence guarantee
  • No native equivalent: requires careful coordination
  • Foundation for others: enables proper composition
  • Zero dependencies: ~800B gzipped
  • Stage 0: ready for community feedback

Todos

  • Codepackages/refs/src/createRef.ts
  • README — comprehensive documentation with SSR focus
  • Tests — 14 specs including SSR safety + stress tests
  • Changeset — minor bump for @solid-primitives/refs
  • Package.json — zero extra dependencies

Copy link

changeset-bot bot commented Aug 7, 2025

🦋 Changeset detected

Latest commit: 25189c5

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

This PR includes changesets to release 1 package
Name Type
@solid-primitives/refs 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

@italoalmeida0 italoalmeida0 changed the title feat(refs): add createRef primitive feat(refs): add createRef primitive, the ultimate solution Aug 8, 2025
@elite174
Copy link

createEffect never runs on server

@italoalmeida0
Copy link
Author

createEffect never runs on server

Hey! Thanks for looking at the PR. You're absolutely right that createEffect doesn't run on server, but the problem goes beyond that.
This proposal actually sparked a great discussion on Discord with Ryan Carniato and the team. Ryan found the direction interesting and it even inspired a Proxy-based implementation.
The real issues createRef/createAttach solves are:

Element existence guarantees (not just SSR)
HMR-safe event handling with keyed listeners
Non-reactive element access for event handlers (avoiding dependency hell)
Shared ref architecture for multiple primitives
Granular cleanup management

I've created a showcase repository with production examples showing where this pattern adds value beyond what createEffect + onMount can handle cleanly.
The discussion evolved to potentially informing Solid 2.0's ref system design. Would love your thoughts on the actual implementation and use cases!

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.

2 participants