β€’

tech

Effect to the rescue

Many are introduced to effect for error handling and dependency injection. Those are huge wins, but effect goes way beyond that. Let me show you a couple of concrete examples.


Sandro Maglione

Sandro Maglione

Software

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 πŸ‘‡


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 the generateObject API πŸͺ„

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 the Stream API 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 πŸ’―

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.

beforeLoad is called on each Link hover 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 xstate actor πŸ€”

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 πŸ‘‹

Start here.

Every week I dive headfirst into a topic, uncovering every hidden nook and shadow, to deliver you the most interesting insights

Not convinced? Well, let me tell you more about it