composable state in RSCs via client references (not client components)
thinking out loud about this "use client" pattern where only the handler is a past the client boundary:
"use client"; // Serialize me as a Client Reference
export function onLike() {
  alert("You liked this.");
}
// MyRSCPage.tsx
import { onLike } from "./onLike";
async function MyRSCPage() {
  return <buttton onClick={onLike} />;
}
which effectively results in:
["button", { onClick: "/src/bundle.js#saveThing" }];
what are we hoping to solve?
maybe something like:
how can i make fewer bespoke components?
eg<DeleteFooButton />,<DeleteBarButton />
or, more hand-wavy:
how can i compose behavior?
โ
 intuitive: simple handlers, eg onClick
โ
 intuitive: handler can fire a toast or whatever
๐ค not intuitive: how to compose reactive and optimistic UI from an RSC?
let's start with this:
// CCButton.tsx
"use client";
export function CCButton({
  isLoading: passedIsLoading = false,
  isDisabled: passedIsDisabled = false,
  onSubmit,
  children,
  ...props
}) {
  const [isLoading, setIsLoading] = useState(passedIsLoading);
  const [isDisabled, setIsDisabled] = useState(passedIsDisabled);
  return (
    <button
      data-loading={isLoading}
      disabled={isDisabled}
      onClick={async () => {
        setIsLoading(true);
        await onSubmit();
        setIsLoading(false);
      }}
      {...props}
    >
      {children}
    </button>
  );
}
// saveThing.ts
"use client";
export async function saveThing() { ... }
// MyRSCPage.tsx
async function MyRSCPage() {
  return <CCButton onSubmit={saveThing} idle="Save" loading="Saving..." />;
}
looking that over...
- we've more effectively maximized "composable behavior" because the handler itself can be used in the RSC
- only the handler is bespoke โ CCButtonis re-usable for all kinds of actions
also, i think CCButton probably needs to be more robust
let's just make sure that it doesn't impact the RSC-level composability (and generally continue to design a "generalised" button for funsies)
- i believe someone could double click a button before the disabled state hits
- i think this โคด is because useStateis effectively "scheduling a UI change"
// CCButton.tsx
"use client";
export function CCButton({
  initialIsLoading = false,
  initialIsDisabled = false,
  onSubmit,
  children,
  ...props
}) {
  const [isLoading, setIsLoading] = useState(initialIsLoading);
  const [isDisabled, setIsDisabled] = useState(initialIsDisabled);
  // useRef to avoid "state drift"
  // eg: prevents double clicks before scheduled update;
  // setState only "schedules the update"; useState is not instant
  const isSubmittingRef = useRef(false);
  const handleClick = async () => {
    if (isSubmittingRef.current) return;
    isSubmittingRef.current = true;
    setIsLoading(true);
    try {
      await onSubmit();
    } finally {
      setIsLoading(false);
      isSubmittingRef.current = false;
    }
  };
  return (
    <button
      data-loading={isLoading}
      disabled={isDisabled || isLoading || isSubmittingRef.current}
      onClick={handleClick}
      {...props}
    >
      {children}
    </button>
  );
}
great, RSC-level composability is the same.
not very exciting, but validating.
also it sort of reinforces the initialXXXX pattern too; while not controlled, you can always just use a key to set an explicit loading state:
<CCButton
  key={foo}
  initialIsLoading={foo}
/>`
...
anyway... putting it all back together:
// CCButton.tsx
"use client";
export function CCButton({
  initialIsLoading = false,
  initialIsDisabled = false,
  onSubmit,
  children,
  ...props
}) {
  const [isLoading, setIsLoading] = useState(initialIsLoading);
  const [isDisabled, setIsDisabled] = useState(initialIsDisabled);
  // useRef to avoid "state drift"
  // eg: prevents double clicks before scheduled update;
  // setState only "schedules the update"; useState is not instant
  const isSubmittingRef = useRef(false);
  const handleClick = async () => {
    if (isSubmittingRef.current) return;
    isSubmittingRef.current = true;
    setIsLoading(true);
    try {
      await onSubmit();
    } finally {
      setIsLoading(false);
      isSubmittingRef.current = false;
    }
  };
  return (
    <button
      data-loading={isLoading}
      disabled={isDisabled || isLoading || isSubmittingRef.current}
      onClick={handleClick}
      {...props}
    >
      {children}
    </button>
  );
}
// saveThing.ts
"use client";
export async function saveThing() { ... }
// MyRSCPage.tsx
async function MyRSCPage() {
  return <CCButton onSubmit={saveThing} idle="Save" loading="Saving..." />;
}
i guess this seems like the right level of composability for this particular "handler" scenario, at least.
- this covers reactive states
- not sure it covers optimistic UI scenarios we may have
- can't really conjure a specific scenario...?
i think my brain keeps trying to do something like "you can't tell saveThing what to optimistically update to" - eg onSubmit{saveThing({ optimisticValue })} because then you're trying to pass a function, not the thing the "use client"-handler was doing which was basically:
["button", { onClick: "/src/bundle.js#saveThing" }];
... but then again: why would the RSC know the optimistic state?
maybe like a "knowing what was being submitted" scenario?
- then i think you're maybe into, like, react-hook-formterritory
- ... which still may not effect RSC composition, which would further affirm we may have found the right level of composition i guess? โ
i suppose we still need to account for the idea that we need to pass SOME data that the handler client reference may need:
// CCButton.tsx
"use client";
interface CCButtonProps<TFn extends (input: any) => Promise<void>> {
  onSubmit: TFn;
  // infer input type from onSubmit
  onSubmitInput?: Parameters<TFn>[0];
  initialIsLoading?: boolean;
  initialIsDisabled?: boolean;
  children: React.ReactNode;
}
export function CCButton<TFn extends (input: any) => Promise<void>>({
  onSubmit,
  onSubmitInput,
  initialIsLoading = false,
  initialIsDisabled = false,
  children,
  ...props
}: CCButtonProps<TFn>) {
  const [isLoading, setIsLoading] = useState(initialIsLoading);
  const [isDisabled, setIsDisabled] = useState(initialIsDisabled);
  // useRef to avoid "state drift"
  // eg: prevents double clicks before scheduled update;
  // setState only "schedules the update"; useState is not instant
  const isSubmittingRef = useRef(false);
  const handleClick = async () => {
    if (isSubmittingRef.current) return;
    isSubmittingRef.current = true;
    setIsLoading(true);
    try {
      await onSubmit(onSubmitInput);
    } finally {
      setIsLoading(false);
      isSubmittingRef.current = false;
    }
  };
  return (
    <button
      data-loading={isLoading}
      disabled={isDisabled || isLoading || isSubmittingRef.current}
      onClick={handleClick}
      {...props}
    >
      {children}
    </button>
  );
}
// saveThing.ts
"use client";
export async function saveThing({ id }: { id: string }) {
  console.log("saved", { id });
}
// MyRSCPage.tsx
async function MyRSCPage() {
  return (
    <CCButton
      onSubmit={saveThing}
      onSubmitInput={{ id: "uuid1234" }}
      idle="Save"
      loading="Saving..."
    />
  );
}
WIP: (haven't followed this through yet)
i'm curious if we can pass a context via client reference too? eg:
// saveThing.ts
"use client";
export async function saveThing({ id }: { id: string }) {
  console.log("saved", { id });
}
// someContext.ts
"use client";
import { createContext } from "react";
// CCButton.tsx
"use client";
interface CCButtonProps<TFn extends (input: any) => Promise<void>> {
  onSubmit: TFn;
  // infer input type from onSubmit
  onSubmitInput?: Parameters<TFn>[0];
  // context, // TODO: example
  initialIsLoading?: boolean;
  initialIsDisabled?: boolean;
  children: React.ReactNode;
}
export function CCButton<TFn extends (input: any) => Promise<void>>({
  onSubmit,
  onSubmitInput,
  // context, // TODO: example
  initialIsLoading = false,
  initialIsDisabled = false,
  children,
  ...props
}: CCButtonProps<TFn>) {
  const [isLoading, setIsLoading] = useState(initialIsLoading);
  const [isDisabled, setIsDisabled] = useState(initialIsDisabled);
  // useRef to avoid "state drift"
  // eg: prevents double clicks before scheduled update;
  // setState only "schedules the update"; useState is not instant
  const isSubmittingRef = useRef(false);
  const handleClick = async () => {
    if (isSubmittingRef.current) return;
    isSubmittingRef.current = true;
    setIsLoading(true);
    try {
      await onSubmit(onSubmitInput);
    } finally {
      setIsLoading(false);
      isSubmittingRef.current = false;
    }
  };
  return (
    <button
      data-loading={isLoading}
      disabled={isDisabled || isLoading || isSubmittingRef.current}
      onClick={handleClick}
      {...props}
    >
      {children}
    </button>
  );
}
// MyRSCPage.tsx
import { saveThing } from "./saveThing";
import { userContext } from "./userContext";
async function MyRSCPage() {
  return (
    <CCButton
      // context={someContext} // TODO: example
      onSubmit={saveThing}
      onSubmitInput={{ id: "uuid1234" }}
      idle="Save"
      loading="Saving..."
    />
  );
}
WIP favorite button
maybe this would be a good example to explore:
consider a component that takes a db record id as a prop and then loads it itself
— Ryan Florence (@ryanflorence) May 15, 2025
now consider it needs to be rendered optimistically but the thing that "adds" and "removes" it is any random component elsewhere (add to favorites)https://t.co/AH8KssVZps
conclusion
hm. cool.
idk.
the end, i guess.