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 โ
CCButton
is 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
useState
is 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-form
territory - ... 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.