Always human authored · no generative AI

Shrödinger's Tiger

Building a reactive UI framework on Effect.ts

Note: This is not going to be a tutorial on how to use Effex, or how it works. If that’s what you’re looing for check out the official Effex website.

Preface

Here’s the thing; I made a frontend framework, and that obviously puts my project in contrast with React. That said, project is not a criticism of React. As a matter of fact, I think React is great. I use it every day in my job. I was actually an early adopter back in 2013. Obviously, it has its quirks, but none of them have ever bothered me that much.

Instead, this library is a love letter to Effect.ts.

At my current job, despite my title as Lead Frontend Enginner, I was tasked with creating a backend service for our product. I was initially told to use Python’s FastAPI. I’ve written a little bit of Python in my career, but I’m not particularly fluent in it. That said, we’re in the era of AI, so with Claude at my side, I was able to vibe-code a proof-of-concept pretty quickly. However, as the product matured, I realized that I needed to write it in a language I was more comfortable in. Translating the project to Typescript took tops 20 minutes with the AI, and all was well.

Except…

It was just a simple backend service. It was exactly what it was supposed to be, and that bored me. I figured since converting the codebase to Typescript was so easy, maybe I could use this as an opportunity to learn something new. I had ben hearing about Effect.ts for a bit. I took a look a while back, but it felt over my head at the time. But now I had a practical example to learn from, and it finally clicked.

An Effect is just a monad! Sure, it’s a special monad, but still, it’s a monad, and I had plenty of experience with monads, having taught myself Haskell a few years back, and applying those lessons in Javascript.

Long story short, working with Effect.ts was a joy. I enjoyed it so much, in fact, that I was a little bummed to go back to the frontend. I wanted those same paradigms in React, and while there are some Effect.ts based libraries available, they left me wanting. It seemed like there was a paradigm mismatch between React and Effect.ts. Furthermore, it seemed like the Effect.ts community was fairly disinterested in the frontend, so I had trouble finding anything that fit my needs. If I wanted to bring the power of Effect.ts to the frontend, I would have to do it myself.

As a side note: At the time I started, I was not aware of FoldKit. I was well into the project by the time I learned about it. That said, I think there’s a world in which Effex and FoldKit can coexist, as they take a fairly different approach to solving the problem.

The Problem

Consider the following React component:

function Profile({ userId }: { userId: string }) {
    return (
        <div>
            <UserInfo userId={userId} />
            <Posts userId={userId} />
        </div>
    );
}

What can we say about this component? Not particularly much from just these seven lines. In fact, I have more questions than answers just reading it.

  • Is it synchronous or asynchronous?
  • Does it have side effects?
  • Can it fail? Throw exceptions? Return errors?
  • Does it suspend?
  • Does it need any Context? If so, what Context specifically?

The return-type of the component is just JSX.Element, which also tells us nothing. Without following the code down the component tree, I can’t answer any of these questions. Consider an alternative:

import { $ } from "@effex/dom";
import { UserInfo } from "./UserInfo";
import { Posts } from "./Posts";

// no JSX, and I'll explain why later
const Profile = ({ userId }: { userId: string }) => 
    $.div(
        UserInfo({ userId }),
        Posts({ userId })
    );

This is an example of Effex code. Unlike the React example, I can actually answer all of these questions just by looking at the type-signature of the component. This is a hypothetical example, but I’m just going to tell you that the return type of this component is:

Element.Element<HTMLDivElement, UserNotFoundError | PostsNotFoundError, AuthContext>

Okay, that’s a lot so let’s break it down. First, Element.Element is a type from @effex/dom that’s really just an Effect under the hood.

export type Element< HTMLElement | SVGElement, MyError, MyContext> =
    Effect.Effect<HTMLElement | SVGElement, MyError, Scope.Scope | RendererContext | MyContext>;

You’ll notice there are three type parameters.

  1. Success Channel: This is what the Effect will return upon sucess. So, something that can be rendered in the DOM.
  2. Error Channel: This is what will be returned if the Effect fails. If the Effect can return more than one error, the Error Channel will contain a union type, as you see in the Profile example.
  3. Context Channel: These are the requirements or dependencies of the Effect. Effect.ts has an awesome dependency injection story, and this channel declares all the dependencies an Effect requires to run. You can think of it kind of like Context in React, but super-charged.

So, just by looking at the type signature, I can answer all of my previous questions.

  • Is it synchronous or asynchronous?

    Effects are asynchronous by default. Async components come for free in Effex!

  • Does it have side effects?

    Yes, it can have side effects through the AuthContext

  • Can it fail? Throw exceptions? Return errors?

    It can fail with either a UserNotFoundError or a PostsNotFoundError.

  • Does it suspend?

    No, it does not have the SuspenseBoundaryCtx, so it does not suspend.

  • Does it need any Context? If so, what Context specifically?

    Yes, it needs the AuthContext.

That’s cool, but do you know what’s cooler? What happens if we try to mount this component and run the app?

import { mount, runApp } from "@effex/dom";
import { Profile } from "./Profile";

runApp(
    mount(
        Profile({ userId: "123" }),
        document.getElementById("root")!
    )
);

This will not compile!

The mount function requires that the Error channel be never and the Context channel can only contain RendererContext and Scope. What this means is that in order to even compile your app (let alone run it), you have to handle all of your errors, and provide all of your dependencies.

The advantage here is that this eliminates a whole class of runtime errors before you’ve even run your app. No more surprises like forgetting to handle a context, or the error state of an API call! By passing around our types like this, we get compile time guarantees about the robustness of our app.

To be fair, this is over-kill for a lot of simpler apps, but if you’re like me, you build complex professional software where correctness is of paramount importance. For those kinds of apps, this extra consideration is worth it.

The Goal

As previously stated, the first and most imporant goal of Effex is the strong typing of the frontend. The type system is a powerful tool to help us build better software, and us frontend developers have been missing out on some of that power. As far as I can tell, this is mostly a legacy issue, as frameworks like React were built before Typescript was as mature or widespread as it is now.

Additionally, as much as I love React, I’m all too aware of some of its shortcomings. I’ll be honest here, I actually don’t have much experience with the other major frameworks like Vue or Svelte, but I believe my extensive experience with React (over a decade now) gives me a pretty good perspective on what’s needed from the next generation of frontend frameworks. So, the following are some of the things I really wanted from my framework.

Proposed Features

  1. Strongly typed & Effectful components Without strong, descriptive types, there’s kind of no point in building this in the first place.

  2. Full compatibility with Effect.ts Effect.ts is a phenomenal library with a wide range of features. I’d be missing out on a huge opportunity if I didn’t write the library in such a way that took advantage of that.

  3. Familiar, declarative API for building UIs Effect.ts can be a little intimidating, but I hoped to wrap that complexity in a welcoming API such that coming to Effex felt like only a slight departure from the traditional frontend libraries.

  4. Surgical DOM updates as opposed to relying on a virtual DOM Honestly, the virtual DOM and its re-rendering rules have always bothered me from a technological perspective. It always felt like a lot of wasted work, regardless of how efficient the diffing algorithm. I’m convinced there’s a more straight forward way to make DOM updates.

  5. First-class support for state with complex types like Array, Map, Set, and Record One of my React pet peeves is that useState doesn’t play nicely with Arrays, Sets, Maps, and Records. I’ve made my share of workaround hooks, but I do that often enough that I wanted to make sure Effex had first-class support for these types from the jump.

  6. First-class support for asynchronous data fetching React doesn’t have a good async story out of the box. Now-a-days, Tanstack Query is basically a required dependency for any React app that needs to fetch data. And credit where it’s due, Tanstack Query has great DX. I wanted to bring that experience to Effex out-of-the-box.

  7. First-class support for enter/exit animations While things like Framer Motion exist, it felt like using a jackhammer, when all I needed was to drive one nail. Exit animations in particular are a pain in React, and I wanted to make sure Effex had a good story for that.

  8. Day-one support for SPA, SSR, and SSG Since we already planned to leverage the full power of Effect.ts, it felt only natural for Effex to support more than just SPA environments. The server-side rendering practically writes itself!

I also hoped to eventually take on mobile, but that’s going to be a whole thing, so I’ll table that for now.

The Process

Let’s be real, while I’m fully capable of writing all of this code myself, it would have taken me years to do so, and I probably would have gotten bored and dropped the whole thing before ever getting to something releasable. But we live in the age of AI, so the amount of work felt more manageable.

That’s actually how the whole thing got started. I was just curious. I just started chatting with Claude about whether such an idea was even possible. After about an hour of back-and-forth, I realized the robot had a whole design doc within its context. So I told it to write out everything we had decided in a Markdown file, complete with code snippets. I then audited the design doc, made some changes here-and-there, and once I was happy, I told Claude to start writing the code.

And within about an hour, I had a working proof of concept of Effex! I kept iterating on it for the next month, and by the end of that time, I had hit most of my goals. I even had a full suite of tests! (That will become important later…) It was pretty exciting, but then I started reading the code it actually wrote.

It. Was. Dogshit.

I couldn’t let the public see this atrocity, especially with my name attached to it. It was so clunky, it was nearly malicious. But it was thousands upon thousands of lines of code. How the hell was I going to clean all this up? I needed a strategy.

Though I had utilized the AI to help me plan the project, I designed the architecture, so I had a pretty good idea of how the code was put together. That said, to be sure, I had the AI write up a full diagram of the architecture. It then went to the most foundational pieces of the library, had the agent refactor, and then worked me way up the stack. However, anyone who’s ever told an AI to “refactor” knows that it actually does quite a piss-poor job. Through trial-and-error, I arived at a methodology for refactoring that proved very effective.

To demonstrate, let’s take a look at the Control functions: when, each, and match* (there’s actually three match functions). I’d love to show code examples here, but the AI-generated versions are so long and convoluted that it wouldn’t really be helpful. What was helpful for me, however, was to have the AI write out a bullet point list of the entire control flow for each function.

Control Flow

when each match
1. Get Renderer and Scope from context 1. Get Renderer and Scope from context 1. Get Renderer and Scope from context
2. Create container element 2. Create container element 2. Create container element
3. Create a new scope 3. Create a new scope 3. Create a new scope
4. Get initial value from the signal 4. Get initial value from the signal 4. Get initial value from the signal
5. Render corresponding onTrue or onFalse branch 5. Iterate over the array and render each item 5. Iterate over the cases and find a match
6. Subscribe to signal changes 6. Subscribe to signal changes 6. Subscribe to signal changes
7. On signal change, render new branch 7. On signal change, check for additions/removals by key 7. On signal change, check for a new match
8. Clean up 8. Clean up 8. Clean up

Do you see the pattern? Of couse some of the steps are identical, but there are some that seem different, but are actually the same. It was upon looking at this that it dawned on me: These are all the same function! Essentially, when and match are just special cases of each. The main difference is the key function. In each that corresponds to a unique key for each element of the array, while in match it corresponds to the case discriminator. In when, the key is just true or false.

So, I had the AI write a new function called reconcile that was a generalized version of this pattern. It took a Readable (from @effex/core), a key function, a render function, and from there it could render the initial state, then subscribe to changes in the Readable, and take the responsibility of adding and removing “slots” from the container, as the Readable changes. Now, when, each, and match were all just thin wrappers around reconcile.

That’s not all! I also was able to use dependency injection to make the reconcile function environment agnostic. It relies on a ControlCtx to provide the specifics on how to read the keys, get the current slots, and add/move/remove slots as the Readable changes. For example, in SSR, we only render once, so the ControlCtx for SSR would just render the initial state and the logic to modify slots would be a no-op.

Composable APIs

The AIs have a bias towards big functions with large configuration objects as parameters. This is kind of against the spirit of Effect.ts, which is all about composability.

Looking at the Router as an example, the AI wrote a function that took a single configuration object with a routes property that was an array of route objects. Each route had its own configuration object, etc. You could see how this would get cumbersome really quickly. Fortunately, I already had solid examples of composable Router APIs from Effect’s platform package.

We went from ugly, disjointed, overly parameterized functions to… well, see for yourself:

const HomeRoute = Route.make("/").pipe(
  Route.render(() => $.div({}, $.of("Welcome home!"))),
);

const AboutRoute = Route.make("/about").pipe(
  Route.render(() => $.div({}, $.of("About us"))),
);

// Compose router
const router = Router.empty.pipe(
  Router.concat(HomeRoute),
  Router.concat(AboutRoute),
  Router.fallback(() => $.div({}, "Not found")),
);

I continued to lean into this pattern wherever possible, opting for small, composable functions over large, monolithic ones. I’ve found when you write this way—composing small primitives—you discover emergent properties you never intended, but work nonetheless.

Yes, sometimes I like a good em-dash in my sentences. AI doesn’t have a monopoly on that!

Dog Food

So, we have a working library, with a pleasant API and a lot of passing tests. But do we actually know it works? It was time to put it to the test. I built some demo apps you can see in the repository, but they were all pretty contrived, and also benefitted from being in the same monorepo as the rest of the library.

So, I decided to show off what the library could really do by building my portfolio site with it (the site you’re currently on!). Putting it through its paces was pretty humbling, as I found a slew of bugs and gaps that my hundreds of unit tests and example apps didn’t catch. Animation in particular was a major pain point, especially as it pertains to hydration and navigation.

All that said, I was able to muscle my way through, and the end result is not only a portfolio site I’m super proud of, but also a library that’s nearing its way toward “production-ready”.

Next Steps

Putting the library through its paces in my SSG portfolio site definitely strengthened the library, but I think next step is building a real interactive SSR app.

Also, I’m playing with the idea of renamig the library altogether, for a few reasons, but that’s a conversation for another time.