Introduction
At Marble, one of the core features of our platform is the Rule Builder. This interface allows users to create and manage detection rules that define how our models operate. It is often the first tool they use to get their detection logic up and running. Because of this, the Rule Builder plays a crucial role in shaping the user experience.
Over time, users began expressing the need for more complex logic. We extended the Rule Builder so it could support nested rules, combinations of conditions, and deeply structured detection logic. This added power, but it also brought new technical challenges. The component became harder to render efficiently as the rule trees grew in size.
When Growth Creates Performance Challenges
The Rule Builder is recursive by design. Each rule can contain sub-rules, and each of those can contain additional nested rules. Some user-created trees can reach hundreds of elements.
Originally, our implementation relied on standard React state management with hooks like useState and useReducer. For small rules this worked perfectly. But as users created more complex detection structures, performance issues started to appear.
For example:
- Editing a rule lower in the hierarchy caused many components above it to re-render
- Adding or removing nodes triggered visible delays
- Even simple input changes could cause noticeable frame drops
React was reconciling the entire subtree under the component where the state changed. That behavior makes sense for many applications, but it is not ideal for a UI composed of hundreds of interconnected units where most do not depend on the updated data.
Profiling to Understand What Was Happening
We used React DevTools and browser profiling tools to visualize what was happening during interaction. Flame charts showed that many components were re-rendering even when their data never changed.
Typing inside an input might cause a re-render of:
- Its parent rule component
- All siblings under that parent
- Many of their children too
React was following its own rules correctly, but we needed a way to make the update system more precise.
Rethinking State Management
We evaluated common optimization techniques. Memoization and React.memo helped reduce some wasted renders, but they still required React to walk through many component boundaries and compare props or state. Context-based state also did not solve the problem since updating a context value re-renders every consumer.
It became clear that we needed a different approach. Instead of relying on React to detect what changed, we needed the ability to update only the components whose actual data changed.
This motivated us to design a mutable and fine-grained store that integrates naturally with React. The result is SharpState, a library we created to introduce fine-grained reactivity into our application. It is available here if you want to try it:
https://www.npmjs.com/package/sharpstate
Why SharpState
SharpState was created with a simple idea in mind. Each piece of state should act as a small reactive signal. When a component reads that signal, it subscribes to it. When the signal updates, only the components that actually depend on that specific value should re-render.
Here is a simplified example:
import { createSharpFactory } from 'sharpstate';
type CounterSharpState = {
count: number;
};
const CounterSharpFactory = createSharpFactory({
name: 'Counter',
initializer: (count: number): CounterSharpState => {
return { count };
},
}).withActions({
increment(api) {
api.value.count += 1;
},
decrement(api) {
api.value.count -= 1;
},
});
const Counter = () => {
const sharp = CounterSharpFactory.createSharp();
return (
<CounterSharpFactory.Provider value={sharp}>
<div className="flex gap-2">
<CounterDisplay />
<button onClick={sharp.actions.increment}>Increment</button>
<button onClick={sharp.actions.decrement}>Decrement</button>
</div>
</CounterSharpFactory.Provider>
);
};
const CounterDisplay = () => {
const count = CounterSharpFactory.select(state => state.count);
return <div>{count}</div>
};After clicking “Increment” or “Decrement, only the <CounterDisplay> component updates. Nothing else in the UI re-renders as you can see in the picture below. There is no prop drilling and no global re-rendering. It feels like working with React, but with a much more precise rendering model.

Applying SharpState to the Rule Builder
Once we confirmed the approach worked, we rewrote the Rule Builder’s state handling to rely on SharpState. Each <RuleNode> retrieves its node from a path and mutates it when updating it.
Here is a simplified example:
const NodeSharpFactory = createSharpFactory({
name: 'BuilderNode',
initializer: ({ node }: { node: AstNode }) => {
return { node };
},
}).withActions({
setNodeAtPath(api, path: Path, newNode: AstNode) {
const parentPath = getParentPath(path);
if (!parentPath) {
api.value.node = newNode;
return;
}
const parentPath = getNodeAtPath(parentPath);
parentPath.children[path.leafId] = newNode;
}
});
function RuleNode({ path }: { path: Path }) {
const nodeSharp = NodeSharpFactory.useSharp();
const node = computed(() => getNodeAtPath(path));
const updateOperator = (operator: string) => {
nodeSharp.setNodeAtPath(path, { ...node, operator });
};
const updateValue = (leafId: string, newNode: AstNode) => {
nodeSharp.setNodeAtPath(path.child(leafId), newNode);
};
return (
<div>
<RuleNode path={path.child(0)} onUpdate={updateValue} />
<SelectOperator onSelect={updateOperator} />
<RuleNode path={path.child(1)} onUpdate={updateValue} />
</div>
);
}If the value changes, only this specific node updates. Other parts of the tree remain untouched, even if they are large and full of nested rules.
This greatly improved responsiveness. Expanding or editing large rule structures now feels instantaneous.
Results
Once we switched to SharpState, the difference was immediately noticeable across the team. The performance issues that previously made the builder feel heavy were no longer present. Developers working on the component also found it easier to reason about. Because each signal is isolated, it is clearer which component will update in response to a change.
We did not capture performance metrics with strict benchmarks, but subjective feedback from both developers and users was very positive. The Rule Builder feels smooth and reliable, even when working with hundreds of nested rule elements.
Key Lessons
A few lessons stood out from this project:
- Fine-grained rendering offers major benefits - Complex UIs do not always map well to React’s rendering model. Letting each component update independently significantly reduces work during interaction.
- Mutable state can be a safe option with proper control- In SharpState, mutations are always explicit and well-contained. This improves both performance and developer understanding.
- Measurement tools guide the way- Profiling helped prevent premature optimization and pointed us directly toward the real issue.
- Custom solutions can be worth exploring- Designing SharpState required time, but it unlocked scalability for a part of our product that users depend on heavily.
Closing Thoughts
Rebuilding the Rule Builder’s data handling taught us that UI performance is not only about optimizing existing code. Sometimes it requires redefining how updates propagate through a system. By introducing precise reactivity through SharpState, we ensured that the Rule Builder remains both powerful and fast as our users create increasingly complex rules.
If you are building a highly interactive and deeply nested UI in React and begin running into performance bottlenecks, it may be worth exploring fine-grained reactivity as well. SharpState is available on npm and we are continuing to refine it based on what we learn in production.
