Optics
website·Indexed 32 days ago
https://www.optics.pageA TypeScript library for managing global immutable state. It provides functional references to read, update, and subscribe to specific parts of an application's state using tools like Lenses, PartialLenses, and combinators such as .derive(), .map(), and .reduce(). Includes a React hook, useOptic, for state integration.
What's inside Optics
- Optics allows you to read, update, and subscribe to any part of a global state. It provides type-safety and code completion when decomposing state using properties or custom selectors, making it specifically efficient for updating deeply nested values.
Understand the role of Optics in immutable state management
Optics are composable references used to focus on specific parts of an immutable data structure to read or update them. In the context of application state management, they bridge the ergonomics gap between mutable and immutable state by solving three primary pain points:
- Deeply Nested Updates: They eliminate the need to manually create shallow copies of every level of a state tree when updating a nested value.
- Entity Relations: They allow representing relations between entities as a graph, removing the need for manual state normalization and denormalization (joining) using IDs.
- Component Decoupling: They allow state to be decomposed so that components can independently read and update specific parts of the state without being tightly coupled to a global store or requiring complex mocks for testing.
Derive a new Optic using .derive()
The
derivemethod creates a new optic based on the current one. It uses agetfunction to transform the focused value and an optionalsetfunction to define how to update the original state when the derived value changes. Thegetfunction is memoized and only executes when the original value changes.If the
setfunction is omitted, the resulting optic is read-only.// Read-write derived optic Optic<A>.derive: <B>(lens: { get: (a: A) => B, set: (b: B, a: A) => A }) => Optic<B>; // Read-only derived optic Optic<A>.derive: <B>(selector: { get: (a: A) => B }) => Optic<B, readOnly>;Find and update the maximum element in an array using max()
Themaxcombinator is used withderiveto create an optic focused on the highest number within a focused array. If the array contains numbers,max()can be called without arguments. If the array contains objects, a selector function must be provided to extract a numeric value for comparison. The optic remains dynamic: if the value of the current maximum element is updated to be lower than another element, the focus automatically shifts to the new maximum element.import { max } from "@optics/react/combinators"; // Example 1: Array of numbers const maxOptic = createState([42, 16, 98, 123, 8]).derive(max()); maxOptic.get(); // 123 maxOptic.set(97); // Updates 123 to 97 maxOptic.get(); // 98 (now the new max)Decouple components from global state using optic props
To keep components reusable and testable, avoid importing global optics directly into child components. Instead, have a root component import the optic and derive specific optics for child components, passing them down via props. This allows child components to remain decoupled from the global state structure, enabling them to be rendered in stories or reused in different parts of the application.import { usersOptic } from "./users"; const UserList = () => { // Use the optic to get the current state value const [users] = useOptic(usersOptic); return ( <ul> {users.map((user, index) => ( <li key={user.name}> {/* Pass a derived optic for the specific user down to the child component */} <User userOptic={usersOptic[index]} /> </li> ))} </ul> ); };Manually derive optics using the derive method
The
derivemethod allows you to create a new optic by defining how to transform the original value into a derived value (get) and how to transform that derived value back into the original state (set).- If only a
getfunction is provided, the resulting optic is read-only and acts as a composable selector. - If both
getandsetare provided, the optic allows bidirectional updates.
// Create a bidirectional optic that transforms an object into a tuple const jeanneStreetTupleOptic = jeanneOptic.address.street.derive({ get: ({ name, number }) => [name, number] as const, set: ([name, number]) => ({ name, number }), }); // Update using the derived tuple format jeanneStreetTupleOptic.set(([name, number]) => [name, number + 10]); // Create a read-only optic (selector) const tupleOptic = streetOptic.derive({ get: ({ name, number }) => [name, number] as const, });- If only a
Decouple components from global state using Optics
To make components more composable and testable, declare optics within the component's props. This explicitly defines the component's dependencies on external state, allowing the component to specify what data it needs without being coupled to the specific structure or location of the global state.Derive individual optics for array elements using getOptics
When an optic is focused on an array, you can use the
getOpticsfunction (returned by theuseOptichook) to derive a unique optic for every element in that array. This is particularly useful for rendering lists where each child component needs its own optic to modify its specific data.To ensure stability and prevent optics from being recreated when the array is modified (e.g., when elements change position), you must provide a key-selector function to
getOptics. This function computes a unique key for each element, similar to how React keys work. The generated key can be used directly as thekeyprop for the rendered React components.import { useOptic } from "@optics/react"; type Props = { usersOptic: Optic<User[]>; }; const Users = ({ usersOptic }: Props) => { // getOptics is extracted from the second element of the useOptic return array const [, { getOptics }] = useOptic(usersOptic); // Pass a function to compute a unique key (e.g., user.id) const optics = getOptics((user) => user.id); return optics.map(([key, userOptic]) => ( <User key={key} userOptic={userOptic} /> )); };Filter, sort, or slice focused values using Optic.reduce()
Thereducemethod is available onmappedoptics. It allows you to derive a new optic focused on one or several values from the mapped optic by applying a reducer function. This is primarily used to sort, filter, or slice the values being mapped over while maintaining the ability to get or set those values through the resulting optic.import { createState } from "@optics/react"; const todosOptic = createState([ { name: "Handle promises", done: false }, { name: "Fix type error", done: true }, { name: "Finish docs", done: false }, { name: "Add tests", done: true }, ]); // Focus only on ongoing (not done) todos const ongoingTodosOptic = todosOptic .map() .reduce((todos) => todos.filter((todo) => !todo.value.done)); ongoingTodosOptic.get(); // [{ name: "Handle promises", done: false }, { name: "Finish docs", done: false }] // Sort by done status, then by name const sortedTodosOptic = todosOptic.map().reduce((todos) => [...todos].sort((a, b) => { const boolOrder = Number(a.value.done) - Number(b.value.done); return boolOrder === 0 ? a.value.name.localeCompare(b.value.name) : boolOrder; }) );Perform multi-level mapping on nested arrays
You can call.map()multiple times if the focused value is an array, even if the optic is already mapped. This creates a flat representation of all nested elements. While.get()returns a flat array of all elements across all parent arrays,.set()preserves the original nested structure of the state.// nationalParksOptic is already mapped // Focus on the 'attractions' array of every park, then map over those attractions const attractionsOptic = nationalParksOptic.attractions.map(); // Returns a flat list of all attractions from all parks attractionsOptic.get(); // Updates every attraction across all parks while preserving the nested array structure attractionsOptic.set(capitalize);Install Optics for React or TypeScript
Optics provides a React-specific package (requiring React 18+) and a framework-agnostic version for general TypeScript applications. Using TypeScript in strict mode is strongly recommended for optimal type inference.# For React applications (React 18+) npm install @optics/react # For framework-agnostic TypeScript applications npm install @optics/stateDerive individual optics for mapped collections using getOpticsFromMapping
For mapped optics (collections handled via themappedutility), use thegetOpticsFromMappingfunction provided by theuseOptichook. This functions identically togetOpticsbut is specifically designed for mapped optics, allowing you to derive a stable optic for each entry in the mapping based on a provided key-selector function.import { useOptic, mapped } from "@optics/react"; type Props = { userMappedOptic: Optic<User, mapped>; }; const Users = ({ userMappedOptic }: Props) => { const [, { getOpticsFromMapping }] = useOptic(userMappedOptic); // Compute a key for each element to maintain optic reference stability const optics = getOpticsFromMapping((user) => user.id); return optics.map(([key, userOptic]) => ( <User key={key} userOptic={userOptic} /> )); };