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
constjeanneAgeOptic =usersOptic [1].age ;
ts
constjeanneAgeOptic =usersOptic [1].age ;
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.
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
constjeanneAgeOptic =useMemo (() =>usersOptic [1].age , [usersOptic ]);
ts
constjeanneAgeOptic =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.
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
constjeanneStreetTupleOptic =jeanneOptic .address .street .derive ({get : ({name ,number }) => [name ,number ] asconst ,set : ([name ,number ]) => ({name ,number }),});
ts
constjeanneStreetTupleOptic =jeanneOptic .address .street .derive ({get : ({name ,number }) => [name ,number ] asconst ,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
conststreetOptic =jeanneOptic .address .street ;consttupleOptic =streetOptic .derive ({get : ({name ,number }) => [name ,number ] asconst ,});
ts
conststreetOptic =jeanneOptic .address .street ;consttupleOptic =streetOptic .derive ({get : ({name ,number }) => [name ,number ] asconst ,});
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";constoldestUserOptic =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";constoldestUserOptic =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
constinterpolOptic =createState ({name : "Interpol",established : "1923",employees : [jeanneOptic ,johnOptic ],});
ts
constinterpolOptic =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
constinterpol =interpolOptic .get ({denormalize : true });// {// name: "Interpol",// established: "1923",// employees: [// { name: "Jeanne", age: 80, address: { ... } },// { name: "John", age: 42, address: { ... } },// ],// }
ts
constinterpol =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";constparisOptic =createState ({name : "Paris",inhabitants : 2_200_000 });constmilanOptic =createState ({name : "Milan",inhabitants : 1_400_000 });constnewYorkOptic =createState ({name : "New York",inhabitants : 8_500_000 });
ts
import {createState } from "@optics/react";constparisOptic =createState ({name : "Paris",inhabitants : 2_200_000 });constmilanOptic =createState ({name : "Milan",inhabitants : 1_400_000 });constnewYorkOptic =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
constusersOptic =createState ([{name : "John",age : 42,address : {city :newYorkOptic },},{name : "Jeanne",age : 32,address : {city :parisOptic },},]);
ts
constusersOptic =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
constinterpol =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
constinterpol =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.
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
constjohnOptic =createState ({name : "John",age : 42,address : {city :newYorkOptic },});constnormalizedJohn =johnOptic .get ();
ts
constjohnOptic =createState ({name : "John",age : 42,address : {city :newYorkOptic },});constnormalizedJohn =johnOptic .get ();
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.
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
interfaceUserProps {userOptic :Optic <User >;}constUser = ({userOptic }:UserProps ) => {const [user ] =useOptic (userOptic );};
tsx
interfaceUserProps {userOptic :Optic <User >;}constUser = ({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";constUserList = () => {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";constUserList = () => {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.