From React to Effect

Aug 17th, 2024

If you know React you already know Effect to a great extent. Let's explore how the mental model of Effect maps to the concept you already know from React.

The History

When I started to program roughly 20 years ago, the world was a very different place. The Web was just about to explode and the capabilities of the web platform were very limited, we were at the beginning of Ajax and most of our web pages were effectively documents rendered from a server with bits of interactivity.

To a good extent it was a simpler world - TypeScript didn't exist, jQuery didn't exist, browsers were doing whatever they wanted, and Java Applets looked like a good idea!

If we fast-forward to today we can easily see that things have changed a lot - the web platform offers incredible capabilities, and most of the programs we are used to interacting with are fully built on the web.

Would it be possible to build what we have today on top of the tech we were using 20+ years ago? Of course, but it wouldn't be optimal. With growing complexity we need more robust solutions. We wouldn't be able to build such powerful user interfaces with ease by sprinkling direct JS calls to manipulate the DOM, without type safety and without a strong model that guarantees correctness.

A lot of what we do today is possible thanks to the ideas brought forward by frameworks such as Angular and React, and here I want to explore why React dominated the market for a decade and why it is still the preferred choice for many.

What we will explore is equally valid for other frameworks, in fact those ideas are not specific to React but far more general.

The Power of React

We should start by asking ourselves, "Why is React so powerful?". When we code UIs in React we think in terms of small components that can be composed together. This mental model allows us to tackle complexity at its heart, we build components that encapsulate the complexity and we compose them in order to build powerful UIs that don't crash constantly and that are sufficiently easy to maintain.

But what is a component?

You may be familiar to writing code that looks like the following:

tsx
const App = () => {
return <div>Hello World</div>;
};
tsx
const App = () => {
return <div>Hello World</div>;
};

Removing JSX, the above code becomes:

ts
const App = () => {
return React.createElement("div", { children: "Hello World" });
};
ts
const App = () => {
return React.createElement("div", { children: "Hello World" });
};

So we can say that a component is a function that returns react elements, or better framed a component is a description or blueprint of a UI.

Only when we mount a component into a specific DOM node (in our example below called "root") our code is executed and the resulting description produces the side-effects that end up creating the final UI.

tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

Let's verify what we've just explained:

tsx
const MyComponent = () => {
console.log("MyComponent Invoked");
return <div>MyComponent</div>;
};
const App = () => {
<MyComponent />;
return <div>Hello World</div>;
};
tsx
const MyComponent = () => {
console.log("MyComponent Invoked");
return <div>MyComponent</div>;
};
const App = () => {
<MyComponent />;
return <div>Hello World</div>;
};

If we run this code, which translates to:

ts
const MyComponent = () => {
console.log("MyComponent Invoked");
return React.createElement("div", { children: "MyComponent" });
};
const App = () => {
React.createElement(MyComponent);
return React.createElement("div", { children: "Hello World" });
};
ts
const MyComponent = () => {
console.log("MyComponent Invoked");
return React.createElement("div", { children: "MyComponent" });
};
const App = () => {
React.createElement(MyComponent);
return React.createElement("div", { children: "Hello World" });
};

we won't see any "MyComponent Invoked" messages in the browser console.

That is because a component was created but it wasn't rendered as it is not part of the returned UI description.

This proves that simply creating a component doesn't perform any side-effects - it is a pure operation, even if the component itself contains side-effects.

Changing the code to:

tsx
const MyComponent = () => {
console.log("MyComponent Invoked");
return <div>MyComponent</div>;
};
const App = () => {
return <MyComponent />;
};
tsx
const MyComponent = () => {
console.log("MyComponent Invoked");
return <div>MyComponent</div>;
};
const App = () => {
return <MyComponent />;
};

will log the "MyComponent Invoked" message to the console, which means side-effects are being performed.

Programming with Blueprints

The key idea of React can be summarized in short with: "Model UI with composable blueprints that can be rendered into the DOM". This is intentionally simplified for the purpose of showing the mental model, of course the details are much more complex but also the details are hidden from the user. This very idea is what makes react flexible, easy to use, and easy to maintain. You can at any point split your components into smaller ones, refactor your code, and you're sure that the UI that was working before keeps working.

Let's take a look at some of the superpowers that React gains from this model, first of all a component can be rendered multiple times:

tsx
const MyComponent = (props: { message: string }) => {
return <div>MyComponent: {props.message}</div>;
};
const App = () => {
return (
<div>
<MyComponent message="Foo" />
<MyComponent message="Bar" />
<MyComponent message="Baz" />
</div>
);
};
tsx
const MyComponent = (props: { message: string }) => {
return <div>MyComponent: {props.message}</div>;
};
const App = () => {
return (
<div>
<MyComponent message="Foo" />
<MyComponent message="Bar" />
<MyComponent message="Baz" />
</div>
);
};

This example is somewhat contrived, but if your component does something interesting (such as modeling a button) this can be quite powerful. You can reuse theButton component in multiple places without rewriting its logic.

A React component may also crash and throw an error, and React provides mechanisms which allow for recovering from such errors in parent components. Once the error has been caught in the parent component, alternative actions, such as rendering an alternative UI, can be performed.

tsx
export declare namespace ErrorBoundary {
interface Props {
fallback: React.ReactNode;
children: React.ReactNode;
}
}
export class ErrorBoundary extends React.Component<ErrorBoundary.Props> {
state: {
hasError: boolean;
};
constructor(props: React.PropsWithChildren<ErrorBoundary.Props>) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError() {
return { hasError: true };
}
render() {
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
}
const MyComponent = () => {
throw new Error("Something went deeply wrong");
return <div>MyComponent</div>;
};
const App = () => {
return (
<ErrorBoundary fallback={<div>Fallback Component!!!</div>}>
<MyComponent />
</ErrorBoundary>
);
};
tsx
export declare namespace ErrorBoundary {
interface Props {
fallback: React.ReactNode;
children: React.ReactNode;
}
}
export class ErrorBoundary extends React.Component<ErrorBoundary.Props> {
state: {
hasError: boolean;
};
constructor(props: React.PropsWithChildren<ErrorBoundary.Props>) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError() {
return { hasError: true };
}
render() {
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
}
const MyComponent = () => {
throw new Error("Something went deeply wrong");
return <div>MyComponent</div>;
};
const App = () => {
return (
<ErrorBoundary fallback={<div>Fallback Component!!!</div>}>
<MyComponent />
</ErrorBoundary>
);
};

While the provided API to catch errors in components may not be very nice, it is not very common to throw within React components. The only real case where one would throw in a component is to throw a Promise that can then be await-ed by the nearest Suspense boundary, allowing components to perform asynchronous work.

Let's have a look:

tsx
let resolved = false;
const promiseToAwait = new Promise((resolve) => {
setTimeout(() => {
resolved = true;
resolve(resolved);
}, 1000);
});
const MyComponent = () => {
if (!resolved) {
throw promiseToAwait;
}
return <div>MyComponent</div>;
};
const App = () => {
return (
<Suspense fallback={<div>Waiting...</div>}>
<MyComponent />
</Suspense>
);
};
tsx
let resolved = false;
const promiseToAwait = new Promise((resolve) => {
setTimeout(() => {
resolved = true;
resolve(resolved);
}, 1000);
});
const MyComponent = () => {
if (!resolved) {
throw promiseToAwait;
}
return <div>MyComponent</div>;
};
const App = () => {
return (
<Suspense fallback={<div>Waiting...</div>}>
<MyComponent />
</Suspense>
);
};

This API is fairly low-level, but there are libraries which leverage it internally to provide features such as smooth data fetching (mandatory shout out to React Query) and data streaming from SSR with server components (the new buzz).

Additionally, because React components are a description of the UI to render, a React component can access contextual data provided by parent components. Let's have a look:

tsx
const ContextualData = React.createContext(0);
const MyComponent = () => {
const context = React.useContext(ContextualData);
return <div>MyComponent: {context}</div>;
};
const App = () => {
return (
<ContextualData.Provider value={100}>
<MyComponent />
</ContextualData.Provider>
);
};
tsx
const ContextualData = React.createContext(0);
const MyComponent = () => {
const context = React.useContext(ContextualData);
return <div>MyComponent: {context}</div>;
};
const App = () => {
return (
<ContextualData.Provider value={100}>
<MyComponent />
</ContextualData.Provider>
);
};

in the above code we defined a piece of contextual data, a number, and we provided it from the top level App component, this way when React renders MyComponent the component will read the fresh data provided from above.

Why Effect

You might ask, why are we spending so much time talking about React? How does this relate to Effect? In the same way that React was, and still is, important for developing powerful user interfaces, Effect is equally important for writing general purpose code. Over the past two decades JS & TS evolved a lot, and thanks to the ideas brought forward by Node.js we now develop full stack applications on top of what people initially thought was a toy language.

As the complexity of our JS / TS programs grow, we again find ourselves in a situation where the demands we put on the platform exceed the capabilities provided by the language. Just like building complex UIs on top of jQuery would be quite a difficult task, developing production-grade applications on top of plain JS / TS has become increasingly painful.

Production-grade application code has requirements such as:

  • testability
  • graceful interruption
  • error management
  • logging
  • telemetry
  • metrics
  • flexibility

and much more.

Over the years, we have seen many features added to the web platform such as AbortController, OpenTelemetry, etc. While all these solutions seem to work well in isolation, they end up failing the test of composition. Writing JS / TS code that fulfills all the requirements of production-grade software becomes a nightmare of NPM dependencies, nested try / catch statements, and attempts to manage concurrency, which ultimately leads to software that is fragile, difficult to refactor, and ultimately unsustainable.

The Effect Model

If we make a short summary of what we've said so far we know that a React component is a description or blueprint of a user interface, similarly we can say that an Effect is a description or blueprint of a generic computation.

Let's see it in action, starting with an example which is very similar to what we have seen initially in React:

ts
import { Effect } from "effect"
const print = (message: string) =>
Effect.sync(() => {
console.log(message)
})
const printHelloWorld = print("Hello World")
ts
import { Effect } from "effect"
const print = (message: string) =>
Effect.sync(() => {
console.log(message)
})
const printHelloWorld = print("Hello World")

Open in Playground

Just like we've seen with React, simply creating an Effect does not result in execution of any side-effects. In fact, just like a component in React, an Effect is nothing more than a blueprint of what we want our program to do. Only when we execute the blueprint do the side-effects kick-off. Let's see how:

ts
import { Effect } from "effect"
const print = (message: string) =>
Effect.sync(() => {
console.log(message)
})
const printHelloWorld = print("Hello World")
Effect.runPromise(printHelloWorld)
ts
import { Effect } from "effect"
const print = (message: string) =>
Effect.sync(() => {
console.log(message)
})
const printHelloWorld = print("Hello World")
Effect.runPromise(printHelloWorld)

Open in Playground

Now we have our "Hello World" message being printed to the console.

In addition, similar to composing multiple components together in React, we can compose different Effects together into more complex programs. To do that we will use a generator function:

ts
import { Effect } from "effect"
const print = (message: string) =>
Effect.sync(() => {
console.log(message)
})
const printMessages = Effect.gen(function*() {
yield* print("Hello World")
yield* print("We're getting messages")
})
Effect.runPromise(printMessages)
ts
import { Effect } from "effect"
const print = (message: string) =>
Effect.sync(() => {
console.log(message)
})
const printMessages = Effect.gen(function*() {
yield* print("Hello World")
yield* print("We're getting messages")
})
Effect.runPromise(printMessages)

Open in Playground

You can mentally map yield* to await and Effect.gen(function*() { }) to async function() {} with the sole difference that if you want to pass arguments, you would need to define a new lambda. For example:

ts
import { Effect } from "effect"
const print = (message: string) =>
Effect.sync(() => {
console.log(message)
})
const printMessages = (messages: number) =>
Effect.gen(function*() {
for (let i = 0; i < messages; i++) {
yield* print(`message: ${i}`)
}
})
Effect.runPromise(printMessages(10))
ts
import { Effect } from "effect"
const print = (message: string) =>
Effect.sync(() => {
console.log(message)
})
const printMessages = (messages: number) =>
Effect.gen(function*() {
for (let i = 0; i < messages; i++) {
yield* print(`message: ${i}`)
}
})
Effect.runPromise(printMessages(10))

Open in Playground

Just like we can raise errors within React components and handle them in parent components, we can also raise errors in an Effect and handle them within parent Effects:

ts
import { Effect } from "effect"
const print = (message: string) =>
Effect.sync(() => {
console.log(message)
})
class InvalidRandom extends Error {
message = "Invalid Random Number"
}
const printOrFail = Effect.gen(function*() {
if (Math.random() > 0.5) {
yield* print("Hello World")
} else {
yield* Effect.fail(new InvalidRandom())
}
})
const program = printOrFail.pipe(
Effect.catchAll((e) => print(`Error: ${e.message}`)),
Effect.repeatN(10)
)
Effect.runPromise(program)
ts
import { Effect } from "effect"
const print = (message: string) =>
Effect.sync(() => {
console.log(message)
})
class InvalidRandom extends Error {
message = "Invalid Random Number"
}
const printOrFail = Effect.gen(function*() {
if (Math.random() > 0.5) {
yield* print("Hello World")
} else {
yield* Effect.fail(new InvalidRandom())
}
})
const program = printOrFail.pipe(
Effect.catchAll((e) => print(`Error: ${e.message}`)),
Effect.repeatN(10)
)
Effect.runPromise(program)

Open in Playground

The above code will randomly fail with a InvalidRandom error, which we then recover from a parent Effect using Effect.catchAll. In this case, the recovery logic is to simply log the error message to the console.

However, what separates Effect from React is that errors are 100% type safe - within our Effect.catchAll, we know that e is of type InvalidRandom. This is possible because Effect uses type inference to understand which error cases your program may encounter and represent those cases in its type. If you check the type of printOrFail, you will see:

Effect<void, InvalidRandom, never>

which means that this Effect will return void if successful but may also fail with an InvalidRandom error.

When you compose Effects that may fail for different reasons, your final Effect will list all possible errors in a union, so you would see something like the following in the type:

Effect<number, InvalidRandom | NetworkError | ..., never>.

An Effect can represent any piece of code, let it be a console.log statement, a fetch call, a database query or a computation. Effect is also fully capable of executing both synchronous and asynchronous code in a unified model, escaping the issue of function coloring (i.e. having different types for async or sync).

Just like React components can access context provided by a parent component, Effects can also access context provided from a parent Effect. Let's see how:

ts
import { Context, Effect } from "effect"
const print = (message: string) =>
Effect.sync(() => {
console.log(message)
})
class ContextualData extends Context.Tag("ContextualData")<ContextualData, number>() {}
const printFromContext = Effect.gen(function*() {
const n = yield* ContextualData
yield* print(`Contextual Data is: ${n}`)
})
const program = printFromContext.pipe(
Effect.provideService(ContextualData, 100)
)
Effect.runPromise(program)
ts
import { Context, Effect } from "effect"
const print = (message: string) =>
Effect.sync(() => {
console.log(message)
})
class ContextualData extends Context.Tag("ContextualData")<ContextualData, number>() {}
const printFromContext = Effect.gen(function*() {
const n = yield* ContextualData
yield* print(`Contextual Data is: ${n}`)
})
const program = printFromContext.pipe(
Effect.provideService(ContextualData, 100)
)
Effect.runPromise(program)

Open in Playground

What separates Effect from React here is that we are not forced to provide a default implementation for our context. Effect keeps track of all requirements of our program in its third type parameter, and will prohibit execution of an Effect that does not have all requirements fulfilled.

If you check the type of printFromContext, you will see:

Effect<void, never, ContextualData>

which means that this Effect will return void upon success, does not fail with any expected errors, and requires ContextualData in order to become executable.

Conclusion

We can see that Effect and React share essentially the same underlying model - both libraries are about making composable descriptions of a program that can later be executed by a runtime. Only the domain is different - React focuses on building user interfaces while Effect focuses on creating general purpose programs.

This is only an introduction and Effect provides much more than what's shown here, this includes features such as:

  • Concurrency
  • Retry Scheduling
  • Telemetry
  • Metrics
  • Logging

And much more.

If you're curious about Effect, please checkout our docs as well as the Effect Beginner Workshop.

If you've made it till here: Thanks for reading.