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...

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)

// 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.

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?

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

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

— Ryan Florence (@ryanflorence) May 15, 2025

conclusion

hm. cool.

idk.

the end, i guess.