Last active
July 30, 2024 03:11
-
-
Save rphlmr/637e939315127779c59fb67297a816d3 to your computer and use it in GitHub Desktop.
Link XState and Remix fetcher
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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