Skip to content

Instantly share code, notes, and snippets.

@rphlmr
Last active July 30, 2024 03:11
Show Gist options
  • Save rphlmr/637e939315127779c59fb67297a816d3 to your computer and use it in GitHub Desktop.
Save rphlmr/637e939315127779c59fb67297a816d3 to your computer and use it in GitHub Desktop.
Link XState and Remix fetcher
import type { ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/node';
import {
ClientActionFunctionArgs,
ClientLoaderFunctionArgs,
useFetcher,
useLoaderData,
} from '@remix-run/react';
import { enqueueActions, fromPromise, setup } from 'xstate';
import { useActor } from '@xstate/react';
import { useEffect, useRef } from 'react';
type Order = { id: string; createdAt: string };
// db mock
const orders: Array<Order> = [];
export function loader() {
return data({ orders });
}
export async function action() {
try {
if (new Date().getTime() % 2 === 0) {
await new Promise((resolve) => setTimeout(resolve, 500));
throw new Error('Oh no!');
}
// do something
await new Promise((resolve) => setTimeout(resolve, 1_000));
const newOrder = {
id: `order-${new Date().getTime()}`,
createdAt: new Date().toDateString(),
} satisfies Order;
orders.push(newOrder);
return data(newOrder);
} catch (cause) {
const message =
cause instanceof Error ? cause.message : 'Something went wrong';
return error(message);
}
}
export default function Route() {
const { data } = useLoaderData<typeof loader>();
const newOrderFetcher = useAsyncFetcher<typeof action>();
const [state, send] = useActor(
OrderMachine.provide({
actors: {
newOrder: fromPromise(async () =>
newOrderFetcher.submit(null, {
method: 'POST',
})
),
},
}),
{
input: {
orders: data.orders,
},
}
);
return (
<div className="font-sans p-4">
<div className="inline-flex gap-2 items-center">
<button
className={`bg-black text-white p-2 rounded ${
state.hasTag('processing') ? 'opacity-50' : ''
}`}
disabled={state.hasTag('processing')}
onClick={() => {
send({ type: 'order.new' });
}}
>
New order
</button>
{state.context.error && (
<p className="text-red-600">{state.context.error.message}</p>
)}
</div>
<h1 className="text-3xl">Orders</h1>
{data.orders.map((order) => (
<p key={order.id}>{order.id}</p>
))}
</div>
);
}
function useAsyncFetcher<T extends LoaderOrActionFunction>() {
const fetcher = useFetcher<T>();
const deferred = useRef<Deferred<T> | null>(null);
useEffect(() => {
if (fetcher.state !== 'idle' || !fetcher.data || !deferred.current) {
return;
}
const response = fetcher.data;
if (response.error) {
return deferred.current.reject(response.error);
}
return deferred.current.resolve(response.data);
}, [fetcher.data, fetcher.state]);
return {
submit: (...args: Parameters<typeof fetcher.submit>) => {
fetcher.submit(...args);
deferred.current = newDeferred();
return deferred.current.promise;
},
};
}
const OrderMachine = setup({
types: {
input: {} as { orders: Array<Order> },
context: {} as { orders: Array<Order>; error: { message: string } | null },
events: {} as { type: 'order.new' },
tags: {} as 'processing',
},
actors: {
newOrder: fromPromise<Order>(async () => {
throw new Error('Did you forget to provide an implementation?');
}),
},
}).createMachine({
context: ({ input }) => ({
orders: input.orders,
error: null,
}),
initial: 'Idle',
states: {
Idle: {
on: {
'order.new': 'Creating new order',
},
},
'Creating new order': {
tags: 'processing',
entry: enqueueActions(({ enqueue }) => {
enqueue.assign({ error: null });
}),
invoke: {
id: 'new-order',
src: 'newOrder',
onDone: {
target: 'Idle',
actions: enqueueActions(({ enqueue, context, event }) => {
enqueue.assign({ orders: context.orders.concat(event.output) });
}),
},
onError: {
target: 'Idle',
actions: enqueueActions(({ enqueue, event }) => {
enqueue.assign({
error: {
// Prefer an helper to assert the event.error type ;)
message: `newOrder:onError ${
(event.error as ErrorResponse['error']).message
}`,
},
});
}),
},
},
},
},
});
type Deferred<T extends LoaderOrActionFunction> = ReturnType<
typeof newDeferred<T>
>;
function newDeferred<T extends LoaderOrActionFunction>() {
let resolve: any;
let reject: any;
const promise = new Promise<
Extract<Awaited<ReturnType<T>>, DataResponse<unknown>>['data']
>((_resolve, _reject) => {
resolve = _resolve;
reject = _reject;
});
return { resolve, reject, promise };
}
type DataResponse<T> = ReturnType<typeof data<T>>;
function data<T>(data: T) {
return {
data,
error: null,
};
}
type ErrorResponse = ReturnType<typeof error>;
function error(message: string) {
return {
data: null,
error: {
message,
},
};
}
type LoaderOrActionResponse<T> = DataResponse<T> | ErrorResponse;
type ActionFunction = (
args: ActionFunctionArgs
) => Promise<LoaderOrActionResponse<unknown>>;
type ClientActionFunction = (
args: ClientActionFunctionArgs
) => Promise<LoaderOrActionResponse<unknown>>;
type LoaderFunction = (
args: LoaderFunctionArgs
) => Promise<LoaderOrActionResponse<unknown>>;
type ClientLoaderFunction = (
args: ClientLoaderFunctionArgs
) => Promise<LoaderOrActionResponse<unknown>>;
type LoaderOrActionFunction =
| LoaderFunction
| ClientLoaderFunction
| ActionFunction
| ClientActionFunction;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment