Skip to main content

Core Concepts

As explained in the previous section it is useful to think of optics as references.
In fact in Haskell, where the concept originated, they are often called functional references. They point to parts of your application's immutable state and let you interact with it.
This parallel is useful to grasp the core concepts of optics because just like references:

  • they can be broken down into smaller parts by calling properties of the object they point to.
  • they can reference other optics and, in doing so, represent relations between your different states.
  • they can be passed around in your application, to your functions and components.

Decompose

We already saw that we can decompose, or break down, an optic into smaller parts by calling properties of the object they point to.

ts
const jeanneAgeOptic = usersOptic[1].age;
const jeanneAgeOptic: Optic<number, {}>
ts
const jeanneAgeOptic = usersOptic[1].age;
const jeanneAgeOptic: Optic<number, {}>

We access the index 1 of the root optic and then the age property, getting us a new optic focused on a narrower part of the initial state.
It's what we call top-down decomposition, we start at the top from a root optic at and we break it down into its sub-parts as we go down the tree.

Now we can read and update this number independently of the surrounding state. In fact we don't have to care if there's a surrounding state at all or if it's an optic direclty returned by createState. Only what's currently focused matters.

info

When deriving an optic in a component body it's better to wrap with in useMemo to avoid recreating a new reference at every render.

ts
const jeanneAgeOptic = useMemo(() => usersOptic[1].age, [usersOptic]);
ts
const jeanneAgeOptic = useMemo(() => usersOptic[1].age, [usersOptic]);

It also greatly simplifies immutable updates: instead of having to shallow copy every level up to the one that interests us (the dreaded spread operator pyramid of doom) we just have to focus on the specific part we want to update and call set on it.

Once we update the underlying value, the subscribers (usually components) will be notified, whether they subscribed to the jeanneAgeOptic optic or any other optic whose value would have changed due to the update.

ts
usersOptic.subscribe(() => console.log("users informations were updated")); // ✅
usersOptic[0].subscribe(() => console.log("John's informations were updated")); // ❌
usersOptic[1].subscribe(() =>
console.log("Jeanne's informations were updated")
); // ✅
jeanneAgeOptic.subscribe((age) =>
console.log(`Jeanne's age was updated to ${age}`)
); // ✅
 
jeanneAgeOptic.set(33);
ts
usersOptic.subscribe(() => console.log("users informations were updated")); // ✅
usersOptic[0].subscribe(() => console.log("John's informations were updated")); // ❌
usersOptic[1].subscribe(() =>
console.log("Jeanne's informations were updated")
); // ✅
jeanneAgeOptic.subscribe((age) =>
console.log(`Jeanne's age was updated to ${age}`)
); // ✅
 
jeanneAgeOptic.set(33);

Console output:

- users informations were updated
- Jeanne's informations were updated
- Jeanne's age was updated to 33
- users informations were updated
- Jeanne's informations were updated
- Jeanne's age was updated to 33

John's subscriber was not notified because the former doesn't know about Jeanne or her age.

caution

Don't use destructuring to derive new optics.
They use proxies under the hood to return new optics from properties so the following won't work:

ts
const { age, name } = jeanneOptic;
ts
const { age, name } = jeanneOptic;

derive method

In addition to calling a property you can also derive new optics manually with the derive method.

It takes an object with two functions, a get to derive a new value from the original one (sometimes called a selector), and a set to update the original value from the derived one:

ts
const jeanneStreetTupleOptic = jeanneOptic.address.street.derive({
get: ({ name, number }) => [name, number] as const,
set: ([name, number]) => ({ name, number }),
});
ts
const jeanneStreetTupleOptic = jeanneOptic.address.street.derive({
get: ({ name, number }) => [name, number] as const,
set: ([name, number]) => ({ name, number }),
});

Here we pass a get function to transform the street object into a tuple, and a set that does the reverse transformation (from tuple to object).
Now we can manipulate the street as a tuple even though it is still represented as an object in the state.

ts
jeanneStreetTupleOptic.set(([name, number]) => [name, number + 10]);
 
jeanneStreetTupleOptic.get(); // ["Rue de Rivoli", 11]
 
jeanneOptic.address.street.get(); // { name: "Rue de Rivoli", number: 11 }
ts
jeanneStreetTupleOptic.set(([name, number]) => [name, number + 10]);
 
jeanneStreetTupleOptic.get(); // ["Rue de Rivoli", 11]
 
jeanneOptic.address.street.get(); // { name: "Rue de Rivoli", number: 11 }

If we don't pass a set function then we get a read-only Optic, an optic that can't be updated and acts effectively as a composable selector:

ts
const streetOptic = jeanneOptic.address.street;
 
const tupleOptic = streetOptic.derive({
get: ({ name, number }) => [name, number] as const,
});
ts
const streetOptic = jeanneOptic.address.street;
 
const tupleOptic = streetOptic.derive({
get: ({ name, number }) => [name, number] as const,
});

Combinators

A combinator is simply a function that returns an object with a get and a set function. You call the combinator inside derive to get a new optic.
The library exports several of these functions under @optics/react/combinators, like find, cond or max.

For example if we want to focus on the oldest user:

ts
import { max } from "@optics/react/combinators";
 
const oldestUserOptic = usersOptic.derive(max((user) => user.age));
 
oldestUserOptic.get(); // { name: "John", age: 42, address: { ... } }
 
jeanneAgeOptic.set(80);
 
oldestUserOptic.get(); // { name: "Jeanne", age: 80, address: { ... } }
ts
import { max } from "@optics/react/combinators";
 
const oldestUserOptic = usersOptic.derive(max((user) => user.age));
 
oldestUserOptic.get(); // { name: "John", age: 42, address: { ... } }
 
jeanneAgeOptic.set(80);
 
oldestUserOptic.get(); // { name: "Jeanne", age: 80, address: { ... } }

Here oldestUserOptic will always point to the oldest user, so when we age poor Jeanne by 47 years, she becomes the oldest user and the optic points to her now.

You are encouraged to create your own combinators if you don't find what you need in the library. They will act as reusable building blocks for your optics.

Compose

We just saw that we can decompose optics (top-down) with props and derive, but we actually can also do the opposite, meaning we can create new optics by composing already existing optics together (bottom-up composition).

To illustrate let's create a new organization with a name, and a list of employees:

ts
const interpolOptic = createState({
name: "Interpol",
established: "1923",
employees: [jeanneOptic, johnOptic],
});
ts
const interpolOptic = createState({
name: "Interpol",
established: "1923",
employees: [jeanneOptic, johnOptic],
});

As you can see the employees we passed are not actual javascript objects but optics we created earlier.

When we call get with { denormalize: true } on interpol's optic it denormalizes the result and replaces the optics by their respective state:

ts
const interpol = interpolOptic.get({ denormalize: true });
// {
// name: "Interpol",
// established: "1923",
// employees: [
// { name: "Jeanne", age: 80, address: { ... } },
// { name: "John", age: 42, address: { ... } },
// ],
// }
ts
const interpol = interpolOptic.get({ denormalize: true });
// {
// name: "Interpol",
// established: "1923",
// employees: [
// { name: "Jeanne", age: 80, address: { ... } },
// { name: "John", age: 42, address: { ... } },
// ],
// }

We have created a new state which is the composition of its own state and references to those of our two previous users.

If we update the state of one of the employees then it's like if the organization state was updated as well so its subscribers will be notified (will re-render if they are components).

To illustrate let's subscribe to the employees, and then give back Jeanne her youth:

ts
interpolOptic.employees.subscribe(
(employees) =>
console.log(`one of the ${employees.length} employees was updated`),
{ denormalize: true }
);
 
jeanneAgeOptic.set(32);
ts
interpolOptic.employees.subscribe(
(employees) =>
console.log(`one of the ${employees.length} employees was updated`),
{ denormalize: true }
);
 
jeanneAgeOptic.set(32);

Console output:

one of the 2 employees was updated
one of the 2 employees was updated

Using optics in the state of an entity is a way to create relations between your different states.
In a relational database we use foreign keys to represent relations between tables, here optics are used for the same purpose. In SQL we use joins to get the final denormalized result, with optics it is done automatically.

State graph

The organization has a reference to its employees but in turn indivual employee can also have references to other entities.
First let's make cities first-class citizens of our state:

ts
import { createState } from "@optics/react";
 
const parisOptic = createState({ name: "Paris", inhabitants: 2_200_000 });
const milanOptic = createState({ name: "Milan", inhabitants: 1_400_000 });
const newYorkOptic = createState({ name: "New York", inhabitants: 8_500_000 });
ts
import { createState } from "@optics/react";
 
const parisOptic = createState({ name: "Paris", inhabitants: 2_200_000 });
const milanOptic = createState({ name: "Milan", inhabitants: 1_400_000 });
const newYorkOptic = createState({ name: "New York", inhabitants: 8_500_000 });

Then we can rework our initial state. Instead of representing a user's city with a string let's use an optic to reference a previously created city:

ts
const usersOptic = createState([
{
name: "John",
age: 42,
address: { city: newYorkOptic },
},
{
name: "Jeanne",
age: 32,
address: { city: parisOptic },
},
]);
ts
const usersOptic = createState([
{
name: "John",
age: 42,
address: { city: newYorkOptic },
},
{
name: "Jeanne",
age: 32,
address: { city: parisOptic },
},
]);

Now the fully denormalized result from interpolOptic looks like that:

ts
const interpol = interpolOptic.get({ denormalize: true });
 
// {
// name: "Interpol",
// established: "1923",
// employees: [
// {
// name: "John",
// age: 42,
// address: {
// city: {
// name: "New York",
// inhabitants: 8_500_000,
// },
// },
// },
// {
// name: "Jeanne",
// age: 32,
// address: {
// city: {
// name: "Paris",
// inhabitants: 2_200_000,
// },
// },
// },
// ],
// }
ts
const interpol = interpolOptic.get({ denormalize: true });
 
// {
// name: "Interpol",
// established: "1923",
// employees: [
// {
// name: "John",
// age: 42,
// address: {
// city: {
// name: "New York",
// inhabitants: 8_500_000,
// },
// },
// },
// {
// name: "Jeanne",
// age: 32,
// address: {
// city: {
// name: "Paris",
// inhabitants: 2_200_000,
// },
// },
// },
// ],
// }

As you can see denormalization is recursive, meaning that if a referenced entity has references of its own, they will be denormalized as well.

You can update a relation by simply replacing the optic with another one.
For example if Jeanne moves to Milan:

ts
usersOptic[1].address.city.set(milanOptic);
ts
usersOptic[1].address.city.set(milanOptic);

Referencing other entities with optics allows us to represent our state as a graph, where the nodes are the entities we build with createState and the edges are the optics we put in the state to make the relations.

Such immutable graphs are traditionally hard to represent in plain JavaScript.
We usually have to resort to representing the relation with the the id of the target entity and then doing what's essentially a manual join to get our denormalized result.
Optics makes the whole process automatic, reactive and type-safe, which is fundamental for non-trivial applications as you eventually always end up needing to represent relations between entities as your application state scales.

info

Denormalization is opt-in, if you don't set the option to true in get or subscribe then it simply returns the state as-is, with the optics still in place:

ts
const johnOptic = createState({
name: "John",
age: 42,
address: { city: newYorkOptic },
});
 
const normalizedJohn = johnOptic.get();
const normalizedJohn: { name: string; age: number; address: { city: Optic<{ name: string; inhabitants: number; }>; }; }
ts
const johnOptic = createState({
name: "John",
age: 42,
address: { city: newYorkOptic },
});
 
const normalizedJohn = johnOptic.get();
const normalizedJohn: { name: string; age: number; address: { city: Optic<{ name: string; inhabitants: number; }>; }; }
cycles

We need our graph to be acyclic to avoid infinite loops when denormalizing.
That means you can't have both the user referencing the city and the city referencing the user, one of them must exclusively own the relation. (I guess my ex was just mindful of graph cycles)

In general it's better to abstain from making the graph overly complex as it can make it easy to accidently introduce cycles, as well as making denormalization slow (just like too many joins in SQL can degrade performance).

Decouple

Another pattern that optics encourages is decoupling your global state from your components.

It can be hard to do when dealing with external state because you might be inclined to import it directly in your components.

To illustrate let's use a fictitious useStore hook implementing the commonly used pattern of selecting a part of the store from the root and subscribing to it:

tsx
import { useStore } from "./myStore";
const User = () => {
const user = useStore((state) => state.users[0]);
};
tsx
import { useStore } from "./myStore";
const User = () => {
const user = useStore((state) => state.users[0]);
};

Here, as noted by the import of the store at line 1, we coupled our component to the store.
The User component will get the user from the same store, using the same path every time. We can't reuse the component to render any another user.

Coupling hurts reusability but also testability: we can't easily render the component in isolation (inside a Storybook, a unit test, ...) since we need to carry with us the surrounding context that the selector needs to get the data.

And that's where lies the problem, the component shouldn't have to know the shape of the global state.
Its only job should be rendering a user, irrespective of where it comes from.

info

We could split our component into smart and dumb ones (or container and presentational) to keep the dumb component decoupled from the state, but at the cost of additional verbosity and nesting in the component tree.

That's where optics come in, as they allow us to naturally decouple our components from the global state simply by passing them to the component's props:

tsx
interface UserProps {
userOptic: Optic<User>;
}
 
const User = ({ userOptic }: UserProps) => {
const [user] = useOptic(userOptic);
};
tsx
interface UserProps {
userOptic: Optic<User>;
}
 
const User = ({ userOptic }: UserProps) => {
const [user] = useOptic(userOptic);
};

Now the User component can be passed any optic focused on a user, allowing you to reuse it anywhere in your application.

tsx
<User userOptic={jeanneOptic} />
<User userOptic={johnOptic} />
tsx
<User userOptic={jeanneOptic} />
<User userOptic={johnOptic} />

When testing you can create a new throwaway user just for your test needs, without having to worry about the rest of the global state:

tsx
import { render } from "@testing-library/*";
test("User renders a user", () => {
const testUserOptic = createState({ name: "Vincent", age: 29, address: { ... } });
render(<User userOptic={testUserOptic} />);
});
tsx
import { render } from "@testing-library/*";
test("User renders a user", () => {
const testUserOptic = createState({ name: "Vincent", age: 29, address: { ... } });
render(<User userOptic={testUserOptic} />);
});

Listing dependencies in the props is always recommended to avoid coupling and there's no reason that it should be otherwise for global state !

Roots and leaves

Of course not all components can get their optics through their props as we have to start somewhere with some initial optics. For example the UserList component is close to the root and imports the usersOptic optic directly:

tsx
import { usersOptic } from "./users";
 
const UserList = () => {
const [users] = useOptic(usersOptic);
 
return (
<ul>
{users.map((user, index) => (
<li key={user.name}>
<User userOptic={usersOptic[index]} />
</li>
))}
</ul>
);
};
tsx
import { usersOptic } from "./users";
 
const UserList = () => {
const [users] = useOptic(usersOptic);
 
return (
<ul>
{users.map((user, index) => (
<li key={user.name}>
<User userOptic={usersOptic[index]} />
</li>
))}
</ul>
);
};

But now that we have this optic the component can derive new optics for each user and pass them down to the User component, which if you remember takes one through a userOptic prop.
In turn the User component itself derives from its optic a new one focused on the user's street and pass it down to a StreetForm component, etc ...

That way once a root component has imported an optic, most components below it can get their optics through their props and stay decoupled from the global state, letting you reuse them elsewhere, test them, render them in stories, etc.

Conclusion

Now that you've learned how to decompose, compose your state, and decouple your components from it, you know every important concept there is to know about state management with optics.

To further your knowledge you can go through the following guides:

  • Partial Optics: understand how a partial optic might not find the value it's focused on.
  • Map/Reduce: learn how to focus multiple values at a time, and then how to reduce the focus back a single value.