DEV Community

Sean Allin Newell
Sean Allin Newell

Posted on • Originally published at sean.thenewells.us on

Effect in React

Effect in React

Emerging from ZIO in the lands of Scala, there is a new ecosystem of functional programming edging its way into TypeScript - Effect.

ZIO: For more about this ZIO and Scala lineage, check out this video from Effect Days 2024.

Behold the power! The following are adaptations taken from the Effect home page, showing examples of what you can do with Effect.

Want to fetch some JSON from an endpoint, handling 200 OK and valid JSON errors, boom.

const getUser = (id: number) =>
  Http.request.get(`/users/${id}`).pipe(
    Http.client.fetchOk,
    Http.response.json,
  )
Enter fullscreen mode Exit fullscreen mode

Want to retry that endpoint if something goes wrong? Did somebody order a one line fix?

--- http.ts 2024-05-21 19:23:24.194145996 +0100
+++ http-with-retry.ts  2024-05-21 19:23:33.524145427 +0100
@@ -3,4 +3,5 @@
     Http.client.fetchOk,
     Http.response.json,
+    Effect.retry({ times: 3 })
   )
Enter fullscreen mode Exit fullscreen mode

How about a more controlled timeout, let's say 3 seconds? Another one liner.

--- http-with-retry.ts  2024-05-21 19:45:49.874052498 +0100
+++ http-with-retry-and-timeout.ts  2024-05-21 19:45:41.354053136 +0100
@@ -3,5 +3,6 @@
     Http.client.fetchOk,
     Http.response.json,
+    Effect.timeout("3 seconds"),
     Effect.retry({ times: 3 }),
   )
Enter fullscreen mode Exit fullscreen mode

Do you want more control on the retry, like an exponential back off? Effect has you covered😎

--- http-with-retry-and-timeout.ts  2024-05-21 19:45:41.354053136 +0100
+++ http-with-retry-timeout-backoff.ts  2024-05-21 19:47:56.084044043 +0100
@@ -4,5 +4,9 @@
     Http.response.json,
     Effect.timeout("3 seconds"),
-    Effect.retry({ times: 3 }),
+    Effect.retry(
+      Schedule.exponential(1000).pipe(
+        Schedule.compose(Schedule.recurs(3)),
+      ),
+    ),
   )
Enter fullscreen mode Exit fullscreen mode

Want a schema to parse the JSON into? Effect.schema. Want to throw in an abort controller? Http takes in a signal.

These are all composable additions to the program we started with, and each of these pieces can be transparently reused and mixed into other Effects. Kind of like how you snap legos together, or how types help us reason about our program. The Effect type is core to this composition, so it's worth a bit of an introduction

The Effect Type

The core of Effect lies in theEffect type, defined here. The docs linked say this about the type parameters:

type Effect<TSuccess, TError, TRequirements> = ...
Enter fullscreen mode Exit fullscreen mode
  • Success : Represents the type of value that an effect can succeed with when executed. If this type parameter is void, it means the effect produces no useful information, while if it is never, it means the effect runs forever (or until failure).
  • Error : Represents the expected errors that can occur when executing an effect. If this type parameter is never, it means the effect cannot fail, because there are no values of type never.
  • Requirements : Represents the contextual data required by the effect to be executed. This data is stored in a collection named Context. If this type parameter is never, it means the effect has no requirements and the Context collection is empty.

We're mostly going to focus on the first two for this first blog post, but don't sleep on Context (aka requirements), as it is how Effect can do Type-first Dependency Injection, nice! ✨

So in addition to the semantic meaning we give the three parameters, the other truly important thing to know about Effect is that it is lazy. The JSDoc block describing the type from the source code says:

The Effect interface defines a value that lazily describes a workflow or job.

This is critical, especially when we bridge the Effectual world to the normal world.

Let's do that now, with a small React app!

βœ‹ We're not going to lay down patterns I'm happy with to ship to production (yet!). I'm still learning Effect after all. If you go on the Effect Discord's react channel, there is chatter about a library called effect-rx which is in active development. I'd encourage you to try to use it if you want to be a pioneer, a potential contributor, or want to see what it has to offer.

In the next blog post I do on Effect, I'll share another stepping stone, and before too long I'll be confident enough to ship to production with Effect in the client πŸ’ͺ! If you're more interested in server side work, you should check out the examplesand the talks, as it is far more "off the shelf" I would say.

useEffectEffect?

πŸ’‘ Yes, the library is, unfortunately, called Effect. And in React, we already have a concept with that name - the useEffect hook. It's worth repeating that the react docs highlight useEffect as a way to "...connect to and synchronize with external systems" for "...non react code...". Some words I've been playing around with to differentiate the two worlds:

  • ReEffect
  • Coalesce
  • Collapse
  • Bridge

Right now, I hate all of them. We'll see what sticks! πŸ˜…

If we fire up a quick vite app and get the famous counter going, we can begin by installing effect (I use pnpm, feel free to use your favorite package manager):

pnpm create vite my-vue-app --template react-ts
# Make sure your tsconfig is strict!

pnpm add effect
Enter fullscreen mode Exit fullscreen mode

πŸ’‘ If you are worried about bundle size, I wouldn't be (yet). At least for our small example things are all still gzipped under 100Kb. The first screenshot below is the default, single chunk of the app we're building today, and the second is with two chunks, a ui chunk for react+react-dom, and a 2nd chunk just for effect. I did not see any difference in bundle size with more specific imports.

Effect in React

Effect in React

Before we do anything useful, let's test our sanity by doing something we think should be trivial, like saying hello world. In Effect, the simplest thing you can do is succeed.

Effect.success("Hello world!")
Enter fullscreen mode Exit fullscreen mode

Great! If we look at the type signature, it describes the program we created by using success: Effect<string, never, never> which means this is a program that when run will yield a string, never error, and requires no context. Cool. 🍨

This is a lazily evaluated program (sort of... we did not pass in a lazy value, so the string is evaluated, we'll get to that), so we now need to execute or resolve it. Let's do that with runSync

const HiEff = Effect.success("Hello world!");
const hi = Effect.runSync(HiEff);

console.log(hi);
Enter fullscreen mode Exit fullscreen mode

Now we see our glorious "Hello world!" in the console, so let's wrap this up in a component and spit it out on the browser

// declare Effects _outside_ of React.
const HiEff = Effect.success("Hello world!");

export const HelloEff = () => {
  // execute Effects once, on mount
  const hi = useMemo(() => Effect.runSync(HiEff), []);

  return <h1>{hi}</h1>;
}

// elsewhere, in App.tsx
return <HelloEff />
Enter fullscreen mode Exit fullscreen mode

Nice! Feel free to change the text and ensure all the react-refresh / hot module reloading / vite / fast go-go juice all works, it does for me!

πŸ€” I've explicitly said to put the Effect outside of React - very purposefully. We'll have to create or leverage Effects that take in user input eventually, but as much as possible put code outside of the render function of a component. That's generally true with and without Effect btw.

Now you may be asking... okay, that's nice, but what about promises and all that async stuff? Well my friend, there's a run promise as well! Keeping our sanity still, let's just try to lift the runSync to promised land and see if we can get it into react, as that part is odd. Since we're going to run our Effect in a Promise, we can't just use the promise in our markup, unless we're on the server and can await or can suspend somehow. The more direct solution is to have some state, just like we would with fetch (or if we were to reach for react-query, just like how react-query stores the response of our n/w call, in state). That looks like this:

// declare Effects _outside_ of React.
const HiEff = Effect.success("Hello world!");

export const HelloEffAsync = () => {
  // Double up as our loading state, and storing the result.
  const [result, setResult] = useState<string>("...");

  useEffect(() => {
    // Behaves the same as Promise.resolve
    Effect.runPromise(HiEff).then(setResult);
  }, []);

  return <h1>{result}</h1>;
};
Enter fullscreen mode Exit fullscreen mode

This looks like it synchronously resolves, so let's add a bit of a delay, with Effect of course, for that one line goodness.

// Leverage Effect.delay instead of timeouts
const HiEff = Effect.delay(Effect.success("Hello world!"), "2 seconds");

export const HelloEffAsync = () => {
  const [result, setResult] = useState<string>("...");

  useEffect(() => {
    Effect.runPromise(HiEff).then(setResult);
  }, []);

  return <h1>{result}</h1>;
};
Enter fullscreen mode Exit fullscreen mode

look ma', no (explicit) setTimeout or whacky promise shenanigans!

Now, with the delay, we actually see the "..." loading string that we used as the initializer to useState first, then the promise resolves. OK - sanity confirmed, async experienced.

❌ Errors & Effect & React

Our sync and async workflows were simple Success Effects, now let's Fail and see what happens. In Effect, you can 'throw' with Effect.fail(...), so let's do that:

const FailEff = Effect.fail("not feeling it...");
Enter fullscreen mode Exit fullscreen mode

Let's see what happens if we runSync a failure:

(FiberFailure) Error: Error: Not feeling it
Enter fullscreen mode Exit fullscreen mode

Oof. An exception, what is this, try-catch town clown 🀑 city?

const FailEff = Effect.fail("Not feeling it");

let result;
try {
    result = Effect.runSync(FailEff);
} catch (e) {
    result = "Exception: " + String(e)
}

console.log(result); // => Exception: (FiberFailure) Error: Not feeling it
Enter fullscreen mode Exit fullscreen mode

At least our program didn't crash, but is there a better way to statically match over the error we know will happen? After all, the type signature of FailEff describes a program that never returns, errors with a string, and needs no context.

There is! There is a type Effect provides called Exit and it has async and sync versions of the run methods, so we wrap our result in a structure we can use. Check out the docs here, and let's take it for a spin:

🧠 Effects are composable, and you don't need to use what you don't understand. If you see something useful, just start with that and see if it helps you make apps with more confidence.

import { Cause, Effect, Exit, identity } from "effect";

function handleLeftRight(left: string, right: string) {
    return `Unexpected exit: (${left}, ${right})`;
}

const FailEff = Effect.fail("Not feeling it");
const ResultExit = Effect.runSyncExit(FailEff);

// Effect provides these handy match functions to map over all possibilities of a type
const result = Exit.match(ResultExit, {
  onSuccess: () => "Were we secretely feeling it?",
  onFailure: (cause) =>
    // The type of cause is Cause, which can
    // encapsulate many kinds of failures.
    Cause.match(cause, {
        // We expect this to be the only path taken, given that our program
        // is Effect<never, string, never>
        onFail: identity,
        onDie: (_defect) => "Unexpected die from a defect\n" + cause.toJSON(),
        onInterrupt: (fiberId) =>
            `Unexpected interrupt on fiber ${fiberId}\n` + cause.toJSON(),
        onParallel: handleLeftRight,
        onSequential: handleLeftRight,
        onEmpty: "Empty"
    })
});

console.info(result); // => "Not feeling it"
Enter fullscreen mode Exit fullscreen mode

Awesome! You may have experienced one of two emotions:

  1. Wow - Effect really makes you cross your ts and dot your is.
  2. That was annoying inside the failure case, if I console log out the cause, I see that it has a failure / error I can just pull out!

By convention (or maybe coercion) there is a _tag field on many (all?) types that support this .match behaviour, so we can do our own kind of collapsing with a switch if we wanted to. This would look like this:

const result = Exit.match(ResultExit, {
  onSuccess: () => "Were we secretely feeling it?",
  onFailure: (cause: Cause.Cause<string>) => {
    // We can do our own pattern matching
    switch (cause._tag) {
        case "Fail":
            return cause.error;
        default:
            return "Unexpected failure";
    }
  }
});
Enter fullscreen mode Exit fullscreen mode

This is still type safe, as _tag is discriminating the union and we still get result: string - but word to the wise, try to leverage match when possible, as it does some narrowing for you (notice the identity we were able to use in the nested match above), which is quite nice to "zoom" in to the underlying type of each case, when we build the switch ourself, TypeScript has narrowed the type, but we have to do the selection and mapping ourself ( cause.error ) - which isn't a big deal in this case, but could be for others.

runPromiseExit: Try to switch to an async version of the sync console program shown above.

Hint - if you are using node v20/bun you can use top level awaits, and the code changes remarkably little!

Now let's bring this home into react! We can start by just trying to do it all in the component, but pretty soon a wee custom hook will "pop" out of this work:

const FailEff = Effect.delay(Effect.fail("I can't even"), "2 seconds");

type States = "processing" | "success" | "failure";

export const HelloEffAsync = () => {
  const [effState, setEffState] = useState<States>("processing");
  const [result, setResult] = useState<string | null>(null);

  useEffect(() => {
    Effect.runPromiseExit(FailEff).then((FailEffExit) => {
      Exit.match(FailEffExit, {
        onSuccess: () => {
          setResult("I CAN even!");
          setEffState("success");
        },
        onFailure: (cause) => {
          Cause.match(cause, {
            onFail: (error) => setResult(error),
            onDie: (_defect) =>
              setResult("Unexpected defect: " + cause.toJSON()),
            onInterrupt: (fiberId) => setResult("Interrupted: " + fiberId),
            onParallel: (_l, _r) => setResult("Unexpected parallel error"),
            onSequential: (_l, _r) => setResult("Unexpected sequential error"),
            onEmpty: null,
          });

          setEffState("failure");
        },
      });
    });
  }, []);

  if (effState === "processing") {
    return <h1>Processing...</h1>;
  }

  if (effState === "success") {
    return <h1 className="text-green-500">{result}</h1>;
  }

  return <h1 className="text-red-500">{result}</h1>;
};
Enter fullscreen mode Exit fullscreen mode

Let's wrap up the reusable promise and exit code, tidy it up a bit, and then we should have halfway decent API to work with:

type States = "processing" | "success" | "failure";

// name pending... naming is hard!
const useEff = <TSuccess, TError>(
  eff: Effect.Effect<TSuccess, TError, never>
) => {
  const [effState, setEffState] = useState<States>("processing");
  const [result, setResult] = useState<TSuccess | null>(null);
  const [error, setError] = useState<TError | string | null>(null);

  // still going to opt for a useEffect mount hook to _run it once per mount_
  // but we will take care of aborting
  useEffect(() => {
    const controller = new AbortController();

    Effect.runPromiseExit(eff, { signal: controller.signal }).then((exit) => {
      Exit.match(exit, {
        onSuccess: (resolvedValue) => {
          setResult(resolvedValue);
          setEffState("success");
        },
        onFailure: (cause) => {
          let setToFailure = true;
          Cause.match(cause, {
            onFail: (error) => setError(error),
            onDie: (_defect) =>
              setError("Unexpected defect: " + cause.toJSON()),
            onInterrupt: (fiberId) => {
              setToFailure = false;
              console.warn(`Interrupted [${fiberId}] - expecting retry`);
            },
            onParallel: (_l, _r) => setError("Unexpected parallel error"),
            onSequential: (_l, _r) => {
              setToFailure = false;
              console.warn("Sequential failure, expecting retry");
            },
            onEmpty: null,
          });

          if (setToFailure) {
            setEffState("failure");
          }
        },
      });
    });

    return controller.abort.bind(controller);
  }, []);

  return {
    result,
    error,
    state: effState,
  };
};
Enter fullscreen mode Exit fullscreen mode

The react code then becomes quite clean, akin to a react-query or similar feel:

const FailEff = Effect.delay(Effect.fail("I can't even"), "1 seconds");

export const HelloEffAsync = () => {
  const { result, error, state } = useEff(FailEff);

  if (state === "processing") {
    return <h1>Processing...</h1>;
  }

  if (state === "success") {
    return <h1 className="text-green-500">{result}</h1>;
  }

  return <h1 className="text-red-500">{error}</h1>;
};
Enter fullscreen mode Exit fullscreen mode

Try switching between fail and succeed, changing the delay, and whatever else comes to mind. You'll see warnings in the console, mostly because React strict mode will mount, unmount, then mount components again which exercises that abort controller we added.

πŸ•° Full Example

Let's take that hook and stick it into a folder with whatever of those names you like most (I think I'll go with re-effect for now), and boom we've started a react effect library. Now let's really exercise what we have more with some shenanigans around Dates and Time.

We're going to do something simple for now, just something to make it easier for Hobbits to see when their next meal is and how to plan accordingly. They'll need to know what day it is, the time (in 12 hour format, because 'murica), the season (for what to wear), and their next meal. Let's add some chaos with "business" rules.

  • We cannot display a time with an even second, only odd. This is because we like jazz too much and cannot live on the down beat, only the off beat (🎺would the off beat be even? or odd? in 'music' beats the first beat is 1 - the down beat, and we emphasize 2 and 4... but in raw timestamps would that be offset? #tangent 🎷). So a timestamp of 00:00:03 is fine (HH:mm:ss format), but 00:00:02 is NOT okay. We must show an error showing the offending, hideous even second.
  • We cannot display a time in the winter, hobbits are not monsters and cannot go out when it is too cold, naturally. Their hair is a fashion statement, not a coat.
  • We cannot display a time that is too early, when hobbits should be sleeping (before 5am).

Other than that, nothing else can go wrong. Because Effect! (And because we're ignoring timezones, for now. 🀑)

All of our rules can be determined from a valid Date object in JS, and we can get the current time with new Date() - there are other variants of this with libraries and feel free to install and use them - but we should wrap that with Effect so we can compose our program together, and sync is the perfect tool for that.

// Effect<Date, never, never>
const GetToday = Effect.sync(() => new Date());
Enter fullscreen mode Exit fullscreen mode

I don't think Date can fail like this, but it certainly can fail if we pass something to it that is invalid, so keep that in mind for any future feature requests or explorations you may elect to do on your own (cough user input cough πŸ’‘).

With the power of Effect, we can pretend stuff exists that we know we can build later - like functions - and start with the modelling of our expected return type and errors. Let's do that now:

// we want something like this, so we can show everything we need to
// the hobbitses
export interface Today {
  monthName: Month; // union of strings January -> December
  ordinalDate: string;
  year: string;
  /** 24 hour time */
  hourNum: number;
  /** 12 hour time */
  hour: string;
  minute: string;
  second: string;
  meridiem: "pm" | "am";
  season: Season; // union of season names
  nextMeal: Meal; // Enough info for name + time of a hobbit meal
}

// we can use simple strings for errors, feel free to use a class
// or anything you'd like: https://effect.website/docs/guides/error-management/expected-errors
type EvenSecond = "SECOND_EVEN";
type TooCold = "TOO_COLD";
type TooEarly = "TOO_EARLY";

type TodayErrors = EvenSecond | TooCold | TooEarly;

// we need this type
type TodayEff = Effect<Today, TodayErrors, never>;
Enter fullscreen mode Exit fullscreen mode

There's a lot of little formatting bits here and there, but the gist is we have that Effect sync that lifts up a Date into the effect world, we have our interface we want React to consume, and we have our errors - now we just need to compose together a new function with our initial function and we should be golden - for now let's just use FlatMap, which will be able to handle the errors / success duality, and return what we need. Oh yeah, and all that stuff I invented like Season/Meal should be made to be real, else it can't compile. 😘

function processDate(date: Date): TodayEff {
  // use Date functions
  // raise invalid states with Effect.fail
  // map to final format of Today with Effect.succeed
}

const GetToday = TodayEff.pipe(Effect.flatMap(processDate));
Enter fullscreen mode Exit fullscreen mode

I'm going to use tailwind to make my app look not like plain html, otherwise the hobbits will laugh at me, but try your hand at it and compare my solution on github to yours! (apologies for the bright light)

A success state:

Effect in React

A few fail states:

Effect in React

Effect in React

Effect in React

And some extra conditions if the hobbit might be running late:

Effect in React

Some notes:

  • You can put a button on the page to remount your component which will re-run your effect, just use key and increment a number or something (see my linked source code)
  • You can use an input and its value existing or not to switch between a TodayEff or an ArbitraryDayEff program, but you'll need to be sure to handle how the Date constructor can fail and create an "Invalid Date" object, yay browsers!

Here is the source code - I will continue to update it as I explore more, the commit where this blog was published was ea158.

What's next?

I don't quite feel satisfied, so I'm going to do two things next:

  1. Interact with an API, coming soon
  2. Investigate a managed runtime to provide context (like whatthis video shows in Remix, code)

Once these two are done, I'll want to try out lots of different kinds of Effects being consumed in React to see where this falls down (as I suspect it will, hence all the work being done in effect-rx). For example, you can declare a Schedule for an Effect, which I think would still work, despite the code we wrote in this post being very "one shot" oriented. And should an async Effect suspend, and how should that work? I'll mostly be looking at react-query and effect-rx / rx-react for inspiration as I move forward.

I'm off work until July this year, so it's a good chunk of time to explore new things! 😁

Top comments (0)