@attrx/lens
v0.2.0
Published
Reactive computed collections with automatic dependency tracking for AttrX
Maintainers
Readme
@attrx/lens
Reactive Computed Collections com tracking automático de dependências para AttrX.
Instalação
pnpm add @attrx/lens @attrx/coreFeatures
- AttrLens - coleção computada reativa (1→1)
- LensComposer - composição de múltiplas lenses (N→M)
- EventEmitter - eventos type-safe
- Tracking automático - descobre sozinho quais props cada item usa
- Cache inteligente - só recomputa o que foi afetado
- UI State embutido - estado de UI separado do SSOT
- 52 testes - cobertura completa com use cases reais
O que é AttrLens?
AttrLens é uma coleção computada reativa - uma "lente" sobre uma AttrCollection que:
- Não é CRUD - não se atualiza imperativamente
- Deriva de fontes - reage a eventos da AttrCollection
- Tracking automático - descobre sozinho quais props cada item usa
- Cache inteligente - só recomputa o que foi afetado
- UI State embutido - estado de UI separado do SSOT
Quick Start
import { AttrCollection } from '@attrx/core';
import { AttrLens } from '@attrx/lens';
// SSOT
const data = new AttrCollection([
{ key: 'node:A', type: 'number', role: 'node', value: 100 },
{ key: 'node:B', type: 'number', role: 'node', value: 200 },
]);
// Lens
const nodesLens = new AttrLens(data, {
uiState: {
defaults: { x: 0, y: 0, selected: false },
},
filter: (key, attr) => attr.role === 'node',
map: (key, attr, ui) => ({
id: key,
value: attr.value,
x: ui.x,
y: ui.y,
selected: ui.selected,
}),
});
// Leitura
nodesLens.getAll(); // [{ id: 'node:A', value: 100, x: 0, y: 0, selected: false }, ...]
nodesLens.get('node:A'); // { id: 'node:A', ... }
// UI State (não afeta SSOT)
nodesLens.setUI('node:A', { x: 100, y: 200 });
data.isDirty(); // false - UI state é separado
// Eventos
nodesLens.on('changed', (changes) => {
console.log('Mudanças:', changes);
});Por que usar AttrLens?
Sem AttrLens
// Manual, repetitivo, ineficiente
function MyComponent({ data }) {
const [nodes, setNodes] = useState([]);
useEffect(() => {
const update = () => {
setNodes(
data.getAll()
.filter(a => a.role === 'node')
.map(a => ({ id: a.key, value: a.value }))
);
};
data.on('valueChanged', update);
data.on('added', update);
data.on('removed', update);
return () => {
data.off('valueChanged', update);
data.off('added', update);
data.off('removed', update);
};
}, [data]);
// Re-renderiza TUDO quando QUALQUER coisa muda
}Com AttrLens
const nodesLens = new AttrLens(data, {
filter: (key, attr) => attr.role === 'node',
map: (key, attr) => ({ id: key, value: attr.value }),
});
// Recomputa só o item que mudou
// Ignora mudanças em props não usadas
// Eventos granularesAPI
Criação
const lens = new AttrLens<T>(collection, {
// Collections extras (opcional)
collections?: Record<string, AttrCollection>,
// UI State embutido
uiState?: {
defaults: Record<string, unknown>,
persist?: 'localStorage:key' | 'sessionStorage:key' | { save, load },
},
// Filter (tracking automático)
filter?: (key, attr, ui, extra) => boolean,
// Map (tracking automático)
map: (key, attr, ui, extra) => T,
// Ordenação
sort?: (a: T, b: T) => number,
// Comparação customizada
equals?: (a: T, b: T) => boolean,
// Debounce (ms)
debounce?: number,
});Leitura
lens.getAll() // T[]
lens.get(key) // T | undefined
lens.has(key) // boolean
lens.length // number
lens.keys // string[]
lens.find(predicate) // T | undefined
lens.filter(predicate) // T[]UI State
lens.getUI(key) // Record<string, unknown>
lens.setUI(key, state) // void
lens.setUIBatch(updates) // void
lens.resetUI(key?) // void
lens.isUIDirty(key?) // boolean
lens.getUIDirtyKeys() // string[]Eventos
lens.on('changed', (changes) => { ... })
lens.on('itemChanged', (key, item, prevItem) => { ... })
lens.on('itemAdded', (key, item) => { ... })
lens.on('itemRemoved', (key, prevItem) => { ... })
lens.on('uiChanged', (key, ui, prevUI) => { ... })Lifecycle
lens.refresh() // Recomputa tudo
lens.refreshKey(key) // Recomputa uma key
lens.dispose() // Cleanup
lens.disposed // booleanDebug
lens.getStats() // { cacheHits, cacheMisses, ... }
lens.getDeps(key) // { filter: Dependencies, map: Dependencies }
lens.getCacheEntry(key) // CacheEntry<T>
lens.resetStats() // voidExemplos
Node Editor
const nodesLens = new AttrLens<FlowNode>(flowData, {
uiState: {
defaults: { x: 0, y: 0, collapsed: false },
persist: 'localStorage:flow-ui',
},
filter: (key, attr) => attr.role?.startsWith('node:'),
map: (key, attr, ui) => ({
id: key,
type: attr.role?.split(':')[1],
data: attr.value,
position: { x: ui.x, y: ui.y },
collapsed: ui.collapsed,
}),
});
// Drag node - só UI, não SSOT
nodesLens.setUI('node:A', { x: 100, y: 200 });
// Mudar valor - SSOT, lens recomputa
flowData.setValue('node:A', { output: 150 });Form Builder
const formLens = new AttrLens<FormField>(formSchema, {
uiState: {
defaults: { focused: false, touched: false, error: null },
},
filter: (key) => key.startsWith('field:'),
map: (key, attr, ui) => ({
name: key.replace('field:', ''),
label: attr.meta?.label,
value: attr.value,
error: ui.error,
showError: ui.touched && ui.error,
}),
});
// Validação
formSchema.on('valueChanged', async ({ key, value }) => {
const error = await validate(key, value);
formLens.setUI(key, { error });
});Multi-Source
const lens = new AttrLens<Item>(mainData, {
collections: {
permissions: permissionsData,
},
map: (key, attr, ui, extra) => ({
id: key,
label: attr.label,
canEdit: extra.permissions.get(key)?.canEdit ?? false,
}),
});LensComposer
Quando você precisa combinar dados de múltiplos slots/lenses (ex: node-flow com nodes + edges):
import { AttrLens, LensComposer } from '@attrx/lens';
// Collection com nodes no root e edges em slot separado
const flow = new AttrCollection([
{ key: 'node:A', type: 'object', role: 'node', value: { x: 0, y: 0 } },
{ key: 'node:B', type: 'object', role: 'node', value: { x: 100, y: 0 } },
{ key: 'edges:A->B', slot: 'edges', type: 'object', value: { from: 'node:A', to: 'node:B' } },
]);
// Lenses separadas (1→1)
const nodesLens = new AttrLens(flow, {
filter: (_, attr) => attr.role === 'node',
map: (key, attr) => ({ id: key, ...attr.value }),
});
const edgesLens = new AttrLens(flow, {
filter: (_, attr) => attr.slot === 'edges',
map: (key, attr) => ({ id: key, ...attr.value }),
});
// Composer junta tudo (N→M)
const flowGraph = new LensComposer({
lenses: { nodes: nodesLens, edges: edgesLens },
compose: ({ nodes, edges }) => ({
nodes: nodes.map(n => ({
...n,
inputs: edges.filter(e => e.to === n.id),
outputs: edges.filter(e => e.from === n.id),
})),
edges,
}),
});
// Reativo
flowGraph.on('changed', (result) => {
// result.nodes[0].inputs → edges conectadas
renderFlow(result);
});
// Leitura
const graph = flowGraph.get();
// { nodes: [{ id, x, y, inputs, outputs }], edges: [...] }Por que Composer?
Sem Composer:
edge muda → recomputa TUDO (filter + map de todos attrs)
Com Composer:
edge muda → edgesLens recomputa só essa edge
→ compose() junta nodes CACHEADOS + edges atualizados
→ só o "join" é refeitoLensComposer API
const composer = new LensComposer({
lenses: { ... }, // Record<string, AttrLens>
compose: (items) => R, // Função de composição (opcional)
debounce?: number, // Debounce em ms
});
composer.get() // Resultado composto
composer.getLens('name') // Items de uma lens específica
composer.getLenses() // Todas as lenses
composer.on('changed', (result, prev) => { ... })
composer.on('lensChanged', (name, changes) => { ... })
composer.refresh() // Força recomputação
composer.dispose() // CleanupUse Cases
| Use Case | Lenses | Output | |----------|--------|--------| | Node-Flow | nodes + edges | nodes com connections | | Kanban | columns + cards | columns com cards agrupados | | Form + Validation | fields + errors | fields enriquecidos | | Tree View | folders + files | estrutura hierárquica |
Performance
O tracking automático via Proxy descobre quais props são usadas:
filter: (key, attr) => attr.role === 'node', // usa: attr.role
map: (key, attr, ui) => ({
value: attr.value, // usa: attr.value
x: ui.x, // usa: ui.x
}),Quando attr.value muda:
- ✅ Re-executa map (usa
value) - ❌ Não re-executa filter (não usa
value)
Quando attr.label muda:
- ❌ Não re-executa filter (não usa
label) - ❌ Não re-executa map (não usa
label) - → Ignora completamente (cache hit)
Licença
MIT
