Skip to main content

Optics

Optics: State Transformation Toolkit

In Refract, optics are reusable “views” into your application state.
They’re like state transformers — a way to focus, transform, and reuse data without duplicating logic.

If a refraction is the entire lens of a camera and a lens is a zoom ring that focuses on one subject, optics are the full toolkit: zoom lenses, filters, prisms, and adapters that let you see the state in exactly the way you need.

Why Optics Exist

UI state isn’t always flat — it’s often nested, conditional, or needs transformation before it’s useful to a component.

Without optics:

  • You’d have to repeat mapping, filtering, or lookup logic across components.
  • Every component that needs a transformed version of state would manage it independently.
  • Updating derived state would mean re-implementing logic in multiple places.

With optics:

  • You define the transformation once.
  • Any component can subscribe to it.
  • Updates flow both ways when the optic supports writing (e.g., lenses).

The Building Blocks

In Refract, optics come in different forms depending on how they interact with state:

Optic TypePurposeRead?Write?
LensFocus on a specific subset of state
PrismFocus on a part of state that may not exist
FoldRead multiple parts of state at once
TraversalApply the same transformation to many items

Analogy:

  • Lens: Zoom into one thing.
  • Prism: Zoom into something that might be missing.
  • Fold: Gather multiple pieces together.
  • Traversal: Apply a change everywhere.

Creating an Optic

import { createRefraction, createLens, createPrism } from 'refract';

const appState = createRefraction({
user: { name: "Alex", email: "alex@example.com" },
notifications: []
});

// Lens for user name
const nameLens = createLens(appState, state => state.user.name);

// Prism for first notification (if it exists)
const firstNotificationPrism = createPrism(
appState,
state => state.notifications[0] ?? null,
(state, newFirst) => {
if (newFirst) state.notifications[0] = newFirst;
}
);

Using Optics in Components

1. Basic Lens

Once you’ve defined an optic — whether it’s a lens, prism, or another type — you can treat it just like a refraction inside your components:

const userLens = createLens(appState, s => s.user);
// Can read AND write to user object

2. Safe Prism

const errorPrism = createPrism(
appState,
s => s.apiResponse?.error ?? null,
(s, newError) => { /* safe write */ }
);

3. Composed Optics

// Combine lenses to create new views
const emailDomain = createLens(
userLens,
user => user.email.split('@')[1]
);

Real-World Patterns

Pattern 1: Form Validation:

const formLens = createLens(appState, s => s.form);
const emailValid = createLens(formLens, form =>
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)
);

// Usage:
function EmailField() {
const isValid = useRefraction(emailValid);
return <input className={isValid ? 'valid' : 'invalid'} />;
}

Pattern 2: API Response Normalization:

const usersTraversal = createTraversal(
apiState,
s => s.users,
(users, updateFn) => users.map(updateFn)
);

This means:

  • Reading from it gives you only the focused or transformed slice of state the optic was designed to expose.

  • Writing to it (if the optic supports writing) updates the source state, automatically triggering any other components or optics that depend on it.

The power of optics here is decoupling state structure from component logic. Your component doesn’t need to know where in the giant application state the data lives or how to extract it — it just subscribes to the optic.

Composing Optics

Optics are composable — you can create a new optic from another:

const domainLens = createLens(appState, s => s.user.email.split('@')[1]);

This lets you build small, focused optics and chain them together to express complex state transformations.

warning

Avoid optics when:

  • State is already flat and simple.

  • Transformation logic is truly unique to a single component.