Optics

website·Indexed 32 days ago

https://www.optics.page

A 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.

Tokens
11.1K
Snippets
62
Records
69
Agent score
63%

What's inside Optics

  1. Manage global state with 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.
  2. 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:

    1. Deeply Nested Updates: They eliminate the need to manually create shallow copies of every level of a state tree when updating a nested value.
    2. Entity Relations: They allow representing relations between entities as a graph, removing the need for manual state normalization and denormalization (joining) using IDs.
    3. 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.
  3. Derive a new Optic using .derive()

    The derive method creates a new optic based on the current one. It uses a get function to transform the focused value and an optional set function to define how to update the original state when the derived value changes. The get function is memoized and only executes when the original value changes.

    If the set function 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>;
  4. Find and update the maximum element in an array using max()

    The max combinator is used with derive to 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)
  5. 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>
      );
    };
  6. Manually derive optics using the derive method

    The derive method 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 get function is provided, the resulting optic is read-only and acts as a composable selector.
    • If both get and set are 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,
    });
  7. 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.
  8. Derive individual optics for array elements using getOptics

    When an optic is focused on an array, you can use the getOptics function (returned by the useOptic hook) 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 the key prop 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} />
      ));
    };
  9. Filter, sort, or slice focused values using Optic.reduce()

    The reduce method is available on mapped optics. 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;
      })
    );
  10. 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);
  11. 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/state
  12. Derive individual optics for mapped collections using getOpticsFromMapping

    For mapped optics (collections handled via the mapped utility), use the getOpticsFromMapping function provided by the useOptic hook. This functions identically to getOptics but 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} />
      ));
    };