Skip to main content

Getting Started

Optics is a library to manage global immutable state in TypeScript applications.

It let's you declare references to parts of your state, called optics, that allow you to read and update these parts as well as subscribe to their changes.
Optics are compositional in nature, making them a natural fit for component based UI frameworks.

Installation

bash
npm install @optics/react
bash
npm install @optics/react

(Requires React 18+)

Other frameworks

Adapters for different UI frameworks are planned as well.
In the meantime you can also use the framework agnostic version of the library:

bash
npm install @optics/state
bash
npm install @optics/state

This library has been built with TypeScript's type inference in mind, using it in strict mode is strongly recommended.

Usage

To create a global state call the createState function and pass it an initial value:

ts
import { createState } from "@optics/react";
 
const usersOptic = createState([
{
name: "John",
age: 42,
address: { city: "New York", street: { name: "5th Avenue", number: 940 } },
},
]);
ts
import { createState } from "@optics/react";
 
const usersOptic = createState([
{
name: "John",
age: 42,
address: { city: "New York", street: { name: "5th Avenue", number: 940 } },
},
]);

It returns us an optic: a reference to a piece of application state that allows us to read, update this state as well as subscribe to it.

Read the state with get:

ts
usersOptic.get(); // [{ name: "John", age: 42, address: { ... } }]
ts
usersOptic.get(); // [{ name: "John", age: 42, address: { ... } }]

Update it with set:

ts
// adds a new user
usersOptic.set((prev) => [
...prev,
{
name: "Jeanne",
age: 32,
address: { city: "Paris", street: { name: "Rue de Rivoli", number: 1 } },
},
]);
 
usersOptic.get(); // [{ name: "John", age: 42, address: { ... } }, { name: "Jeanne", age: 32, address: { ... } }]
ts
// adds a new user
usersOptic.set((prev) => [
...prev,
{
name: "Jeanne",
age: 32,
address: { city: "Paris", street: { name: "Rue de Rivoli", number: 1 } },
},
]);
 
usersOptic.get(); // [{ name: "John", age: 42, address: { ... } }, { name: "Jeanne", age: 32, address: { ... } }]

Subscribe to it with subscribe:

ts
usersOptic.subscribe((users) => {
console.log("users changed: ", users);
});
ts
usersOptic.subscribe((users) => {
console.log("users changed: ", users);
});
tip

You're not restricted to a single global state, you can call createState as many times as you want.

Deriving new optics

Where it gets interesting is that, from this base optic, you can get new ones focused on different parts of your state !

For exemple let's get an optic that focuses on the city of the first user in our list:

ts
const cityOptic = usersOptic[0].address.city;
const cityOptic: Optic<string, total>
ts
const cityOptic = usersOptic[0].address.city;
const cityOptic: Optic<string, total>

Now we can directly read and update the city of the first user with this optic:

ts
cityOptic.get(); // "New York"
 
cityOptic.set("Boston");
 
cityOptic.get(); // "Boston"
ts
cityOptic.get(); // "New York"
 
cityOptic.set("Boston");
 
cityOptic.get(); // "Boston"
Let's compare that with the manual way of updating immutable data:
ts
// 😵‍💫
const newState = [
{
...users[0],
address: {
...users[0].address,
city: "Boston",
},
},
...users.slice(1),
];
ts
// 😵‍💫
const newState = [
{
...users[0],
address: {
...users[0].address,
city: "Boston",
},
},
...users.slice(1),
];

Using optics saves us from quite the boilerplate when updating deeply nested data !

Optics let you focus on narrower parts of your state, allowing you to read and update these parts independently of the rest.

Another example:

ts
const jeanneStreetOptic = usersOptic[1].address.street;
const jeanneStreetOptic: Optic<{ name: string; number: number; }, total>
 
jeanneStreetOptic.number.set(42);
 
jeanneStreetOptic.get(); // { name: "Rue de Rivoli", number: 42 }
ts
const jeanneStreetOptic = usersOptic[1].address.street;
const jeanneStreetOptic: Optic<{ name: string; number: number; }, total>
 
jeanneStreetOptic.number.set(42);
 
jeanneStreetOptic.get(); // { name: "Rue de Rivoli", number: 42 }
tip

Deriving a new optic looks just like accessing properties of an object !
You get the same type-safety and code completion in your editor as with plain javascript objects.

info

For now don't worry about the total type parameter, you'll be able to learn about that later in the total and partial section of the docs.

Usage in components

Your React components can subscribe to optics and re-render when the focused states change.

Call the useOptic hook with an optic, it returns the current value inside a tuple.

tsx
import { useOptic } from "@optics/react";
 
const StreetForm = () => {
const [street] = useOptic(jeanneStreetOptic);
 
return (
<div>
<input
value={street.number}
type="number"
onChange={(e) => jeanneStreetOptic.number.set(parseInt(e.target.value))}
/>
{street.name}
</div>
);
};
tsx
import { useOptic } from "@optics/react";
 
const StreetForm = () => {
const [street] = useOptic(jeanneStreetOptic);
 
return (
<div>
<input
value={street.number}
type="number"
onChange={(e) => jeanneStreetOptic.number.set(parseInt(e.target.value))}
/>
{street.name}
</div>
);
};

The component will re-render when the street changes, whether it is changed from within the component or from elsewhere in the application.

ts
// outside of the component
jeanneStreetOptic.number.set((prev) => prev + 1);
 
// StreetForm re-renders 🔄
ts
// outside of the component
jeanneStreetOptic.number.set((prev) => prev + 1);
 
// StreetForm re-renders 🔄

However the component will not re-render if an unrelated part of the state changes.

ts
jeanneOptic.age.set((prev) => prev + 1);
 
// Jeanne's street reference hasn't changed, StreetForm doesn't re-render 🚫
ts
jeanneOptic.age.set((prev) => prev + 1);
 
// Jeanne's street reference hasn't changed, StreetForm doesn't re-render 🚫

Pass optics in props

Instead of referencing the optic directly, the component can accept one via its props.

tsx
import { useOptic, Optic } from "@optics/react";
 
interface Props {
streetOptic: Optic<{ name: string; number: number }>;
}
 
const StreetForm = ({ streetOptic }: Props) => {
const [street] = useOptic(streetOptic);
// ...
};
tsx
import { useOptic, Optic } from "@optics/react";
 
interface Props {
streetOptic: Optic<{ name: string; number: number }>;
}
 
const StreetForm = ({ streetOptic }: Props) => {
const [street] = useOptic(streetOptic);
// ...
};

Now StreetForm isn't coupled to a specific part of the state anymore, it can take any optic that's focused on a street.

tsx
<>
{/* Form for john's street */}
<StreetForm streetOptic={usersOptic[0].address.street} />
 
{/* Form for Jeanne's street */}
<StreetForm streetOptic={usersOptic[1].address.street} />
</>;
tsx
<>
{/* Form for john's street */}
<StreetForm streetOptic={usersOptic[0].address.street} />
 
{/* Form for Jeanne's street */}
<StreetForm streetOptic={usersOptic[1].address.street} />
</>;

Next steps

Now that you know the basics of optics you can go through the core concepts to have a better grasp of the notions introduced here and learn about optic composition.