Lenses
The lens is your gateway to Refract's reactive world. Think of it as a magic toolbox that gives your components superpowers - the ability to create reactive state, run side effects, and connect to the reactive system.
Every Refract component receives a lens object as its first parameter, and this lens is your component's personal connection to all of Refract's features.
Why Lenses?
In traditional React, you have to import different hooks for different purposes - useState for state, useEffect for side effects, useContext for shared data. With Refract, everything flows through the lens.
The lens provides a consistent, scoped interface to all reactive features. It ensures that each component has its own isolated space while still being able to connect to shared state and effects.
What Makes Lenses Special?
-
Scoped Access: Each component gets its own lens instance, providing proper isolation and scoping for reactive features.
-
Unified Interface: One object gives you access to all of Refract's reactive features - state, effects, optics, and more.
-
Automatic Cleanup: When a component unmounts, the lens automatically cleans up all associated state and effects.
-
Type Safety: Lenses provide excellent TypeScript support with full type inference.
How lens scoping Works
Each component gets its own lens instance, creating isolated scopes for state and effects. This is crucial for component isolation and preventing conflicts.
Step-by-Step: Understanding Lens Scoping
import { createComponent } from "refract";
const ParentComponent = createComponent(({ lens }) => {
// Step 1: This lens belongs only to this ParentComponent instance
const parentState = lens.useRefraction("parent");
// ↑ This state is scoped to this specific component
// Step 2: Effects are also scoped to this lens
lens.useEffect(() => {
console.log("Parent effect runs once");
}, []);
// ↑ This effect belongs to this component's lens
return (
<div>
<p>Parent: {parentState.value}</p>
{/* Step 3: Each child gets its own lens */}
<ChildComponent />
<ChildComponent />
</div>
);
});
const ChildComponent = createComponent(({ lens }) => {
// Step 4: Each ChildComponent instance gets its own lens
const childState = lens.useRefraction("child");
// ↑ Even though both children use the same initial value,
// they get separate state because they have separate lenses
lens.useEffect(() => {
console.log("Child effect - runs for each instance");
}, []);
// ↑ This effect runs separately for each child component
return (
<div>
<p>Child: {childState.value}</p>
<button onClick={() => childState.set(Math.random().toString())}>
Update This Child Only
</button>
</div>
);
});
What happens here:
- ParentComponent gets its own lens - manages its own state and effects
- Each ChildComponent gets its own lens - even though there are two
<ChildComponent/>instances, each has isolated state - State is isolated - updating one child's state doesn't affect the other child
- Effects are isolated - each component's effects run independently
This scoping ensures that components don't interfere with each other, even when using the same component multiple times.
Lens Methods Overview
The lens object provides access to all of Refract's reactive features:
1. State Management with Lenses (lens.useRefraction)
Local state management:
// Local state - scoped to this component
const formData = lens.useRefraction({
name: "",
email: "",
age: 0
});
const errors = lens.useRefraction({});
const isSubmitting = lens.useRefraction(false);
const updateField = (field, value) => {
formData.set({
...formData.value,
[field]: value
});
};
const validateForm = () => {
const newErrors = {};
if (!formData.value.name.trim()) {
newErrors.name = "Name is required";
}
if (!formData.value.email.includes("@")) {
newErrors.email = "Invalid email";
}
errors.set(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async () => {
if (!validateForm()) return;
isSubmitting.set(true);
try {
await submitForm(formData.value);
// Reset form on success
formData.set({ name: "", email: "", age: 0 });
} catch (error) {
console.error("Submit failed:", error);
} finally {
isSubmitting.set(false);
}
};
return (
<form onSubmit={(e) => { e.preventDefault(); handleSubmit(); }}>
<input
type="text"
value={formData.value.name}
onChange={(e) => updateField("name", e.target.value)}
placeholder="Name"
/>
{errors.value.name && <span className="error">{errors.value.name}</span>}
<input
type="email"
value={formData.value.email}
onChange={(e) => updateField("email", e.target.value)}
placeholder="Email"
/>
{errors.value.email && <span className="error">{errors.value.email}</span>}
<button type="submit" disabled={isSubmitting.value}>
{isSubmitting.value ? "Submitting..." : "Submit"}
</button>
</form>
);
});
Connecting to global state:
import { createRefraction } from "refract";
// Global state
const globalTheme = createRefraction("light");
const globalUser = createRefraction(null);
const Header = createComponent(({ lens }) => {
// Connect to global state through lens
const theme = lens.useRefraction(globalTheme);
const user = lens.useRefraction(globalUser);
const toggleTheme = () => {
theme.set(theme.value === "light" ? "dark" : "light");
};
return (
<header className={\`header theme-\${theme.value}\`}>
<h1>My App</h1>
<div className="user-info">
{user.value ? (
<span>Welcome, {user.value.name}!</span>
) : (
<span>Please log in</span>
)}
</div>
<button onClick={toggleTheme}>
Switch to {theme.value === "light" ? "dark" : "light"} theme
</button>
</header>
);
});
2. Side Effects with Lenses
useEffect - for controlled side effects
const DataComponent = createComponent(({ lens, userId }) => {
const user = lens.useRefraction(null);
const loading = lens.useRefraction(true);
const error = lens.useRefraction(null);
// Effect runs when userId changes
lens.useEffect(() => {
loading.set(true);
error.set(null);
fetch(\`/api/users/\${userId}\`)
.then(response => {
if (!response.ok) throw new Error("Failed to fetch");
return response.json();
})
.then(userData => {
user.set(userData);
loading.set(false);
})
.catch(err => {
error.set(err.message);
loading.set(false);
});
}, [userId]); // Runs when userId changes
// Cleanup effect
lens.useEffect(() => {
const interval = setInterval(() => {
console.log("Periodic task");
}, 5000);
// Cleanup function
return () => {
clearInterval(interval);
};
}, []); // Runs once on mount
if (loading.value) return <div>Loading...</div>;
if (error.value) return <div>Error: {error.value}</div>;
if (!user.value) return <div>User not found</div>;
return (
<div>
<h2>{user.value.name}</h2>
<p>{user.value.email}</p>
</div>
);
});
useFlash or one-time effects after render
const AnimatedCard = createComponent(({ lens, isNew }) => {
const cardRef = lens.useRefraction(null);
// Flash effect runs once after render
lens.useFlash(() => {
if (isNew && cardRef.value) {
// Animate the card entrance
cardRef.value.style.transform = 'scale(0.8) translateY(20px)';
cardRef.value.style.opacity = '0';
requestAnimationFrame(() => {
cardRef.value.style.transition = 'all 0.3s ease';
cardRef.value.style.transform = 'scale(1) translateY(0)';
cardRef.value.style.opacity = '1';
});
}
});
return (
<div
ref={el => cardRef.set(el)}
className="animated-card"
>
<h3>New Card</h3>
<p>This card animates on first render</p>
</div>
);
});
useComputed to derived state:
const ShoppingCart = createComponent(({ lens }) => {
const items = lens.useRefraction([
{ id: 1, name: 'Laptop', price: 999, quantity: 1 },
{ id: 2, name: 'Mouse', price: 25, quantity: 2 }
]);
// Computed values automatically update when items change
const totals = lens.useComputed(() => {
const subtotal = items.value.reduce((sum, item) =>
sum + (item.price *item.quantity), 0
);
const tax = subtotal* 0.08;
const total = subtotal + tax;
const itemCount = items.value.reduce((sum, item) =>
sum + item.quantity, 0
);
return { subtotal, tax, total, itemCount };
}, [items.value]);
const updateQuantity = (id, quantity) => {
items.set(
items.value.map(item =>
item.id === id ? { ...item, quantity } : item
)
);
};
return (
<div className="shopping-cart">
<h2>Shopping Cart ({totals.value.itemCount} items)</h2>
{items.value.map(item => (
<div key={item.id} className="cart-item">
<span>{item.name}</span>
<span>\${item.price}</span>
<input
type="number"
value={item.quantity}
onChange={e => updateQuantity(item.id, parseInt(e.target.value))}
min="1"
/>
<span>\${item.price * item.quantity}</span>
</div>
))}
<div className="cart-totals">
<p>Subtotal: \${totals.value.subtotal.toFixed(2)}</p>
<p>Tax: \${totals.value.tax.toFixed(2)}</p>
<h3>Total: \${totals.value.total.toFixed(2)}</h3>
</div>
</div>
);
});
useOptic to integrate custom logic patterns:
Custom optic for localStorage persistence
function useLocalStorage(key, initialValue) {
const value = useRefraction(initialValue);
// Load from localStorage on mount
useOptic(() => {
const saved = localStorage.getItem(key);
if (saved) {
try {
value.set(JSON.parse(saved));
} catch (error) {
console.warn(\`Failed to parse localStorage value for key "\${key}"\`, error);
}
}
}, []);
// Save to localStorage when value changes
useOptic(() => {
localStorage.setItem(key, JSON.stringify(value.value));
}, [value.value]);
return value;
}
// Using the custom optic in a component
const Settings = createComponent(({ lens }) => {
// Use the custom optic through lens.useOptic()
const theme = lens.useOptic(() => useLocalStorage("theme", "light"));
const language = lens.useOptic(() => useLocalStorage("language", "en"));
return (
<div className="settings">
<h2>Settings</h2>
<div className="setting">
<label>Theme:</label>
<select
value={theme.value}
onChange={(e) => theme.set(e.target.value)}
>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</div>
<div className="setting">
<label>Language:</label>
<select
value={language.value}
onChange={(e) => language.set(e.target.value)}
>
<option value="en">English</option>
<option value="es">Spanish</option>
<option value="fr">French</option>
</select>
</div>
</div>
);
});
Related resource
- Learn about Optics - reusable logic patterns
- Explore the useEffect API reference
- Check out Refractions for state management