Custom Optics
Custom optics let you create reusable, composable logic patterns that work seamlessly with Refract's reactivity system. Unlike traditional React hooks, optics provide dependency-aware caching, animation-aware transitions, and automatic cleanup—making them ideal for complex stateful logic.
What are custom optics?
Custom optics are functions that encapsulate reactive state, side effects, and computed values into reusable units. They follow the same principles as React hooks but with enhanced capabilities:
- Dependency-aware caching: Automatically memoize expensive computations
- Animation-aware transitions: Integrate smoothly with Refract's animation system
- Automatic cleanup: Handle resource disposal without manual intervention
- Composability: Combine multiple optics seamlessly
Creating custom optics
Use the createOptic() function to build custom optics:
import { createOptic, useRefraction } from "refract";
const useCounter = createOptic((initialValue = 0) => {
const count = useRefraction(initialValue);
const increment = () => count.set(count.value + 1);
const decrement = () => count.set(count.value - 1);
const reset = () => count.set(initialValue);
return {
count: count.value,
increment,
decrement,
reset
};
});
Advanced optics with side effects
Handle API calls, subscriptions, and other side effects:
import { createOptic, useRefraction, useEffect } from "refract";
const useApiData = createOptic((url) => {
const data = useRefraction(null);
const loading = useRefraction(false);
const error = useRefraction(null);
const fetchData = async () => {
loading.set(true);
error.set(null);
try {
const response = await fetch(url);
const result = await response.json();
data.set(result);
} catch (err) {
error.set(err.message);
} finally {
loading.set(false);
}
};
useEffect(() => {
fetchData();
}, [url]);
return {
data: data.value,
loading: loading.value,
error: error.value,
refetch: fetchData
};
});
Optic composition patterns
Combining multiple optics
Create higher-order optics by combining existing ones:
const useAuthenticatedApi = createOptic((url) => {
const auth = useAuth();
const api = useApiData(auth.token ? url : null);
if (!auth.isAuthenticated) {
return {
data: null,
loading: false,
error: "Authentication required"
};
}
return api;
});
Conditional optics
Use conditional logic to create adaptive behavior:
const useResponsiveLayout = createOptic(() => {
const windowSize = useWindowSize();
const isMobile = windowSize.width < 768;
const mobileLayout = useOptic(() => ({
columns: 1,
spacing: "sm"
}), [], { enabled: isMobile });
const desktopLayout = useOptic(() => ({
columns: 3,
spacing: "lg"
}), [], { enabled: !isMobile });
return isMobile ? mobileLayout : desktopLayout;
});
Performance optimization
Dependency tracking
Optics automatically track dependencies, but you can optimize further:
const useExpensiveComputation = createOptic((data, options) => {
const result = useRefraction(null);
// Only recompute when data changes, not options
useEffect(() => {
const computed = expensiveFunction(data);
result.set(computed);
}, [data]); // options intentionally omitted
return result.value;
});
Selective reactivity
Control which changes trigger updates:
const useThrottledSearch = createOptic((query, delay = 300) => {
const debouncedQuery = useRefraction(query);
const results = useRefraction([]);
useEffect(() => {
const timeout = setTimeout(() => {
debouncedQuery.set(query);
}, delay);
return () => clearTimeout(timeout);
}, [query, delay]);
useEffect(() => {
if (debouncedQuery.value) {
searchApi(debouncedQuery.value).then(results.set);
}
}, [debouncedQuery.value]);
return {
results: results.value,
isSearching: query !== debouncedQuery.value
};
});
Animation integration
Optics work seamlessly with Refract's animation system:
import { createOptic, useRefraction, useFlash } from "refract";
const useAnimatedCounter = createOptic((initialValue = 0, duration = 300) => {
const target = useRefraction(initialValue);
const current = useRefraction(initialValue);
useFlash(() => {
// Animate to target value
animate(current.value, target.value, {
duration,
onUpdate: (value) => current.set(value)
});
}, [target.value]);
return {
value: current.value,
setValue: target.set,
isAnimating: current.value !== target.value
};
});
Error handling
Implement robust error boundaries in optics:
const useSafeAsyncOperation = createOptic((operation) => {
const result = useRefraction(null);
const error = useRefraction(null);
const loading = useRefraction(false);
const execute = async (...args) => {
loading.set(true);
error.set(null);
try {
const data = await operation(...args);
result.set(data);
return data;
} catch (err) {
error.set(err);
console.error("Optic operation failed:", err);
throw err;
} finally {
loading.set(false);
}
};
return {
result: result.value,
error: error.value,
loading: loading.value,
execute
};
});
Testing custom optics
Test optics in isolation using Refract's testing utilities:
import { renderOptic } from "refract/testing";
describe("useCounter", () => {
test("increments count", () => {
const { result, rerender } = renderOptic(() => useCounter(5));
expect(result.current.count).toBe(5);
result.current.increment();
rerender();
expect(result.current.count).toBe(6);
});
test("resets to initial value", () => {
const { result, rerender } = renderOptic(() => useCounter(10));
result.current.increment();
result.current.reset();
rerender();
expect(result.current.count).toBe(10);
});
});
Best practices
Naming conventions
- Prefix custom optics with
use - Use descriptive names that indicate purpose
- Group related optics in modules
// Good
const useUserProfile = createOptic(...);
const useShoppingCart = createOptic(...);
// Avoid
const optic1 = createOptic(...);
const getData = createOptic(...);
Resource management
Always clean up resources to prevent memory leaks:
const useWebSocket = createOptic((url) => {
const socket = useRefraction(null);
const messages = useRefraction([]);
useEffect(() => {
const ws = new WebSocket(url);
socket.set(ws);
ws.onmessage = (event) => {
messages.set(prev => [...prev, event.data]);
};
// Cleanup function
return () => {
ws.close();
socket.set(null);
};
}, [url]);
return {
socket: socket.value,
messages: messages.value
};
});
Granular reactivity
Keep optics focused on specific concerns:
// Good - focused responsibility
const useCurrentUser = createOptic(...);
const useUserPreferences = createOptic(...);
const useUserNotifications = createOptic(...);
// Avoid - too broad
const useEverythingAboutUser = createOptic(...);
Common patterns
State machines
Implement finite state machines with optics:
const useStateMachine = createOptic((initialState, transitions) => {
const state = useRefraction(initialState);
const transition = (event) => {
const currentState = state.value;
const nextState = transitions[currentState]?.[event];
if (nextState) {
state.set(nextState);
} else {
console.warn(`Invalid transition: ${currentState} -> ${event}`);
}
};
return {
state: state.value,
transition,
can: (event) => Boolean(transitions[state.value]?.[event])
};
});
Undo/redo functionality
const useUndoRedo = createOptic((initialValue) => {
const history = useRefraction([initialValue]);
const currentIndex = useRefraction(0);
const setValue = (value) => {
const newHistory = history.value.slice(0, currentIndex.value + 1);
newHistory.push(value);
history.set(newHistory);
currentIndex.set(newHistory.length - 1);
};
const undo = () => {
if (currentIndex.value > 0) {
currentIndex.set(currentIndex.value - 1);
}
};
const redo = () => {
if (currentIndex.value < history.value.length - 1) {
currentIndex.set(currentIndex.value + 1);
}
};
return {
value: history.value[currentIndex.value],
setValue,
undo,
redo,
canUndo: currentIndex.value > 0,
canRedo: currentIndex.value < history.value.length - 1
};
});
Custom optics are the foundation of reusable logic in Refract. By following these patterns and best practices, you can create maintainable, testable, and performant applications.