You may think you don't need effect until... π€―
This is the story of many problems my app hit over time, and how effect solved them one after the other with no effort π
You may think you don't need @EffectTS_ until Your AI is slow -> Stream Always same request -> Cache Requests hanging -> Timeout Requests failing -> Retry Unknown issue -> Observability All out of the box, in mostly 1 line of code each π―
Slow AI: Streaming
For the initial MVP, it was easier to just make a "normal" API call, wait, and get back the full AI response.
That was already easy enough with
@effect/ai, using thegenerateObjectAPI πͺ
But then the prompt became more complex, and with it increased also the waiting time and user frustration.
Let's move to streaming then. Sounds complex? Well, it's mostly a single function change: from generateObject to streamText ππΌββοΈ
You get an effect
Stream, and theStreamAPI has all sort of mapping/filtering already available for you πͺ
Bonus: NDJSON
With streaming, a full structured JSON response doesn't fit anymore.
I want to parse/send an array of responses one by one, instead of waiting for the full JSON to complete π€
That's what NDJSON is for (Newline Delimited JSON).
I initially implemented my own Stream solution to parse NDJSON outputs, before effect solved this for me as well with the Ndjson module (@effect/platform).
One line change, and all works π―
Typical @EffectTS_ experience π Use the API to implement your solution, then discover that somewhere there is already a module for that, and replace all with 1 line πͺ
Scope and timeouts
I was integrating the Posthog API (analytics). The SDK requires calling shutdown to clean resources.
Easy with effect: using Scope and acquireRelease β¨
export class Posthog extends Effect.Service<Posthog>()("Posthog", {
scoped: Effect.gen(function* () {
const { apiKey, host } = yield* Config.all({
apiKey: Config.redacted("POSTHOG_API_KEY"),
host: Config.string("POSTHOG_API_HOST"),
});
const client = yield* Effect.acquireRelease(
Effect.sync(
() => new _PostHog(Redacted.value(apiKey), { host })
),
(client) => Effect.promise(() => client.shutdown())
);
// ...
}),
}) {}But then I noticed another problem, what if shutdown itself gets stuck? π€
It theory it should be fast, but in practice you want to timeout if it takes too long for whatever reason π€·πΌββοΈ
Again, one line change with effect and we are done, using Effect.timeout:
export class Posthog extends Effect.Service<Posthog>()("Posthog", {
scoped: Effect.gen(function* () {
const { apiKey, host } = yield* Config.all({
apiKey: Config.redacted("POSTHOG_API_KEY"),
host: Config.string("POSTHOG_API_HOST"),
});
const client = yield* Effect.acquireRelease(
Effect.sync(
() => new _PostHog(Redacted.value(apiKey), { host })
),
(client) =>
Effect.promise(() => client.shutdown()).pipe(
Effect.timeout("2 seconds"),
Effect.ignore
)
);
// ...
}),
}) {}Caching
I am using the beforeLoad API of TanStack Router to verify the user status.
beforeLoadis called on eachLinkhover event, all the times, so it must be fast β‘οΈ
My beforeLoad was performing an API request, all the times, every time the same, for all hover events. Not good.
That's where caching comes in. And again, with effect is a one line change with withRequestCaching:
export const Route = createFileRoute("/_authenticated/_dashboard")({
beforeLoad: async ({ context }) => {
const { status } = await RuntimeClient.runPromise(
Effect.gen(function* () {
const apiRequest = yield* ApiRequest;
const accessToken = yield* Auth.getToken(context.auth.getToken);
return yield* Effect.request(
new GetAccountStatus({ token: accessToken }),
apiRequest.getAccountStatus
).pipe(
Effect.withRequestCaching(true)
);
}).pipe(
Effect.catchAll(() => Effect.dieMessage("Failed to get account status"))
)
);
// ...
},
// ...Queue events
The Paddle client SDK (payments) provides a callback function to listen for events π«
I want to extract those events and pipe them to an
xstateactor π€
Well, effect ππΌββοΈ
With the Queue module I can push events to a queue, and then use Stream to send them to xstate:
export class Paddle extends Effect.Service<Paddle>()("Paddle", {
effect: Effect.gen(function* () {
const runtime = yield* Effect.runtime();
const runPromise = Runtime.runPromise(runtime);
const eventQueue = yield* Queue.unbounded<PaddleEventData>();
const eventCallback = (event: PaddleEventData) => {
void runPromise(Queue.offer(eventQueue, event));
};
const paddle = yield* Effect.promise(() =>
initializePaddle({ token, environment: "sandbox", eventCallback })
);
return {
eventStream: Stream.fromQueue(eventQueue),
// ...
};
}),
}) {}Stream.fromQueue gives me back a Stream, and I once again can use all the Stream API to send events to the xstate state machine πͺ
These are just a few examples. It's a testimony of how effect goes beyond error handling and dependency injection (which are huge wins already).
So, you probably do need effect after all π€
See you next π
