Effect & React Query - With Prefetching!
In my last few posts we saw how to start working with Effect in a pure client side application with React. One of the first things we do in SPAs is interact with remote data, and the leader in this space of remote-data-synchronization is React Query (TanStack Query [TSQ from now on]) - I mean if Theo says so it must be true right?
How would we leverage the out of box caching, prefetching, etc. from TSQ in an Effect client application? And how would we fetch remote resources in the first place? Just fetch
? effect-http
? rpc
? Well, the true answer is "it depends" on your application and context, but let's invent a context so I can provide a clear answer.
- Pure client SPA, no dedicated server (if there is a server, we have no control).
- We know the shape of the data the server returns, we should error if it changes in a breaking way.
- We want to be able to support offline-first (we won't do this here, but there will be a way to do this).
- Our app should be fault tolerant and always render something useful or informative to the user.
Given the above constraints, I can recommend the built-in HttpClient
in Effect from the platform package - specifically fetchOk from HttpClient.fetchOk
. We can compose this with TSQ by usingEffect.runPromise
to move from the Effect world into the Promise world that TSQ expects.
Let's look at some code!
Fetching in Effect
The easiest way to start fetching structured data in Effect is to define a schema and use it in an HttpClientRequest
pipeline with fetchOk
, that looks like this:
const API_URL = "https://some.api.data/get";
const ApiResponse = Schema.Struct({
id: Schema.Number,
name: Schema.String
});
// tracer fails CORs check due to the server being out of our control
export const GetApiData = Effect.withTracerEnabled(false)(
HttpClientRequest.get(API_URL, { acceptJson: true }).pipe(
HttpClient.fetchOk,
Effect.andThen(HttpClientResponse.schemaBodyJson(ApiResponse)),
Effect.scoped
)
);
// test this with runPromiseExit:
Effect.runPromiseExit(FetchTodaysFact)
.then(d => {
if (d._tag === "Success") {
console.info("we fetched structured data: ", d);
} else {
console.error("A bad thing occured, the cause: ")
console.error(JSON.stringify(d.cause));
}
});
In a real app we would handle the errors more comprehensively with a matcher, but this is already far better than a simple fetch with all its try/catch
and .catch
calls required for proper status code and JSON parsing handling.
But we don't have caching... yet!
Compose Effect with TanStack Query
TSQ can use any promise returning function for its query function; axios, fetch, or Effect.runPromise*
all work great. I like to declare query options outside instead of inline (we'll reuse it to pre-fetch later), so with that in mind a simple TSQ+Effect setup would be:
import { QueryClient, queryOptions } from "@tanstack/react-query";
// normal normal
const queryClient = new QueryClient();
const GET_DATA_OPTIONS = queryOptions({
queryKey: ["data"],
queryFn: () => Effect.runPromise(GetApiData),
// crazy aggressive caching pls
staleTime: 3_600_000, // 60 * 60 * 1_000
gcTime: Infinity,
});
// use it!
const App = Effect.gen(function* () {
const result = yield* Effect.promise(() =>
queryClient.ensureQueryData(GET_DATA_OPTIONS)
);
console.info(`We got ${result.name} back`);
});
Effect.runFork(App);
Neat! But... the use of queryClient
seems a little sus - isn't that a global variable? Normally in React to use TSQ properly we leverage the hook useQuery
which pulls Context that we set up with TSQ's provider. What should we do in Effect land to coordinate the use of the queryClient
singleton across multiple Effects?
Services! The Effect-ified DI solution. This is that Requirements/Context third type parameter. When I have a singleton like this, wrapping it up in a Service seems the most straight forward. The convention is to have a generic Service declaration, akin to an interface or domain layer, and a *Live
implementation for production which can be swapped out for a *Test
implementation in tests.
As the linked docs above show, we use Context
and Layer
to create and manage these requirements.
import { QueryClient } from "@tanstack/react-query";
import { Context, Layer } from "effect";
const queryClient = new QueryClient();
export class APIService extends Context.Tag("APIService")<
APIService,
{
readonly queryClient: QueryClient;
}
>() {};
export const APIServiceLive = Layer.succeed(APIService, {
queryClient: queryClient,
});
Now we can rewrite our use of TSQ's queryClient
with a Layer
and Provide
👍
// let's rename this to "AppEff" to distinguish it from an App component
export const AppEff = Effect.gen(function* () {
// App now requires APIService to be provided
const { queryClient } = yield* APIService;
const result = yield* Effect.promise(() =>
queryClient.ensureQueryData(GET_DATA_OPTIONS)
);
console.info(`We got ${result.name} back`);
return result;
});
// when we run it, we provide the live API Service
Effect.runFork(Effect.provide(AppEff, Layer.succeed(APIServiceLive));
But now React pls?
I've written the beginnings of a library which I'm tentatively calling re-effect
which will handle the merging of Layers, and give us a Provider and React.Context
. We'll leverage usePromise
which has been updated to look for and use the Contextual layers. For our example, the root React entry point is able to Provide all the services our app needs (just the one here).
// declare and expose Live production services
export const Services = Layer.mergeAll(APIServiceLive); // variadic!
// in App.tsx / index.tsx / main.tsx or wherever
import { LayerProvider } from "re-effect";
import { Services } from "./registry";
export const App = () => {
<LayerProvider layer={Services}>
<Whatever />
</LayerProvider>
}
Then, in a component we can just use any Effect that has the requirements we've provided. If something is not provided we'll get an error (not compile time yet... unsure how that would work with React's loosey-goosey composition model, but could be achievable as a lint rule or TS Plugin?):
import { AppEff } from "./domain";
import { usePromise } from "re-effect";
const Loading = () => <div>...</div>;
export const ShowData = () => {
// re-effect uses strongly typed matchers OOTB <3
const [match] = usePromise(AppEff);
return match({
Empty: Loading,
Pending: Loading,
Error: (err) => <div>oops {JSON.stringify(err)}</div>,
Success: ({ id, name }) => <p>#{id} : {name}</p>,
});
};
This has not been tested, but is my intention.
Prefetch!
prefetchQuery
on the client. Here is a starting stackblitz.It's not the best UX to have popcorn loaders or otherwise have components start their fetching as they render - it's far superior to have a fetch initiated by a parent or higher up the tree so the data is ready. So let's use an intent-based approach, pre-fetching our query on mouse enter/hover, caching, and then revealing the info. We'll need a new Effect to prefetch this, and a parent component to wrap up ShowData behind some state.
Since our brains are slowly rotting and turning into LLM mush, let's think about this step by step 💀
- In TSQ land, we just need to call
prefetchQuery
- In Effect land, we've wrapped up our api call in an Effect as we need to inject a Service to use the query client singleton.
- We likely need a new Effect.
- It would be nice to still encapsulate data fetching + pre fetching into the component for larger apps.
- We could just fire it off right away, but it'd be nicer to do an intent-based prefetch.
I hope that provided enough vertical space so I can slap the code here and you weren't too tempted to look beyond before at least opening the stack blitz and thinking about it 😏
Anyways, here are some further thoughts:
- For our quick example, I've thrown things in the same file because it is simple. See a new stackblitz link below the prefetching code sample.
- In this git repo, I've attached a static member called
getPrefetcher
to the component that does the data fetching as a way to retain some encapsulation, because I personally view this performance and UX admission as a break in component encapsulation/composition. Don't feel like you have to do that.
// define the prefetch effect
export const PrefetchFactEff = Effect.gen(function* () {
const { queryClient } = yield* APIService;
queryClient.prefetchQuery(GET_DATA_OPTIONS);
});
const Loading = () => <div>...</div>;
// fire (or await already in progress) off req and show data here:
function ShowFact() {
const [match] = usePromise(FetchFactEff);
return match({
Empty: Loading,
Pending: Loading,
Error: (err) => <div>oof... {JSON.stringify(err)}</div>,
Success: ({ result }) => <div>
<h2>FACT:</h2>
<p>{result}</p>
</div>
});
}
/** Custom hook that returns a callback to prefetch today */
const usePrefetcher = () => {
const layer = useLayer<APIService, never>();
return useCallback(
() => Effect.runPromise(Effect.provide(PrefetchFactEff, layer)),
[]
);
};
// re-org our component to have mouse intent-based prefetching!
export function App() {
const prefetch = usePrefetcher();
const [showFact, setShowFact] = useState(false);
// something something react 19 compiler, usecallback, blah blah
function revealFact() {
setShowFact(true);
}
return <div>
<h1>Click to Reveal a Fact</h1>
<button onMouseEnter={prefetch} onClick={revealFact}>SHOW IT TO ME</button>
{showFact ? <ShowFact /> : null}
</div>
}
The above works in this forked stackblitz.
Caveats
- I think this approach uses each library where they are strongest. I view this as a kind of library composition and I feel that is very powerful. I suspect this strategy can get us pretty far.
- I haven't touched mutations / suspense. I think it will still work, but it's something that needs an example/test b4 publishing ReEffect. (could TSQ's suspense query work? Should we invert our Effect sandwich of using queryClient so queryFn calls out and unwraps Effects?)
- There are other options as detailed in prior blog posts (rx/fx), but they each seem to make more demands of the React codebase, or are perhaps better suited for a server+client environment (think rpc/rpc-http), or have more power as they introduce new Types/concepts (streams).