Explore Blogs

Emulating SolidJS reactivity in React without signals

bpi-solidjs-like-reactivity-in-react-apps

Fine-grained reactivity in React can optimize performance by reducing unnecessary component re-renders. This approach allows you to handle forms, data updates, and other interactive features efficiently, especially in high-performance applications.

Have you ever heard the phrase "Reactivity without re-renders" and wondered what magic powers Solid.js is using behind the scenes? Solid.js boasts ultra-performant, fine-grained reactivity that can update parts of your UI without triggering full component re-renders. But what if I told you that you can achieve a similar pattern in React — without ditching your React stack and without introducing Solid's signal system?

Yes, it's possible. No, you don't need to eject your app. This post will walk you through the idea of fine-grained reactivity and then step by step show how you can emulate Solid-like patterns in plain React using native patterns like refs, subscriptions, and custom hooks.

Whether you're just getting comfortable with React or you've been building apps for years, this guide will help you rethink how state updates and renders work under the hood — and how you can break the rules without breaking your app.

The problem with react renders

React's rendering model is simple on the surface: when state changes, the component re-renders. But this comes with overhead. Even with memoization, useCallback, and useMemo, we often find ourselves chasing performance bugs.

Let's see an example.

function Parent() {
  const [count, setCount] = useState(0);

  return (
    <>
      <ExpensiveChild />
      <button onClick={() => setCount(count + 1)}>{count}</button>
    </>
  );
}

function ExpensiveChild() {
  console.log('Rendering ExpensiveChild');
  return <div>I am expensive</div>;
}

Every time you click the button, React re-renders the whole Parent, and that means ExpensiveChild renders again — even though it didn't need to. We can use React.memo, but now we're managing memoization manually and introducing complexity.

This isn't "fine-grained". In Solid.js, this wouldn't happen.

What SolidJS does differently

Solid uses a reactive system inspired by reactive primitives (like signals), not VDOM diffs. Each piece of state is a fine-grained reactive primitive. Changes to one signal only affect subscribers of that signal — no virtual DOM diffing, no component re-renders.

const count = createSignal(0);

createEffect(() => {
  console.log(count()); // only re-runs when count changes
});

This reactivity is closer to MobX or Vue's reactive system, where updates are directly tied to the data, not the component lifecycle.

The good news? You can simulate something similar in React — without bringing in a signal system. It takes a bit more boilerplate, but you'll get precise control.

The concept of fine-grained reactivity in react

To replicate this in React, we need to break away from the setState-render cycle and instead use mutable shared state, combined with subscriptions that trigger updates only where needed.

Let's dive into an example.

Creating a reactive store

Let's build a tiny reactive store — just a plain JavaScript object with subscribers.

function createReactiveValue(initial) {
  let value = initial;
  const subscribers = new Set();

  return {
    get: () => value,
    set: (next) => {
      if (value === next) return;
      value = next;
      subscribers.forEach(fn => fn(value));
    },
    subscribe: (fn) => {
      subscribers.add(fn);
      return () => subscribers.delete(fn);
    }
  };
}

Usage:

const count = createReactiveValue(0);

const unsubscribe = count.subscribe(newVal => {
  console.log("Value changed:", newVal);
});

count.set(1); // logs "Value changed: 1"

This is the backbone of reactivity: observable state and subscriptions. Now let's connect this to React.

Connecting to react without causing full renders

Here's where the magic happens. We want to update only the parts of the UI that care about the data — without forcing re-renders of parent components.

import { useSyncExternalStore } from 'react';

function useReactiveValue(reactive) {
  return useSyncExternalStore(
    reactive.subscribe,
    reactive.get
  );
}

Now we can use this reactive value inside a component:

function CounterDisplay({ count }) {
  const value = useReactiveValue(count);
  return <div>Count is {value}</div>;
}

And your parent component stays untouched, even if the count changes.

Updating reactively

Now wire it all together.

const count = createReactiveValue(0);

function App() {
  return (
    <div>
      <CounterDisplay count={count} />
      <button onClick={() => count.set(count.get() + 1)}>Increment</button>
    </div>
  );
}

Clicking the button updates only the CounterDisplay, not the whole App. We just achieved fine-grained reactivity.

No state in App. No re-renders when count changes. Just efficient updates.

Example: Reactively updating a list item

Let's say you have a list of 100 items, but only one item's name changes. With React state, the whole list may re-render. With fine-grained reactivity, only the changed item updates.

function createReactiveArray(initialArray) {
  return initialArray.map(item => ({
    id: item.id,
    name: createReactiveValue(item.name)
  }));
}

Then:

function ListItem({ item }) {
  const name = useReactiveValue(item.name);
  return <li>{name}</li>;
}

function ItemList({ items }) {
  return <ul>
    {items.map(item => <ListItem key={item.id} item={item} />)}
  </ul>;
}

Only the affected ListItem re-renders when a name changes.

Benefits of this pattern

  • No unnecessary component renders
  • Explicit control over what updates
  • No dependency on external libraries or signals
  • Great for performance-critical apps

Gotchas and tradeoffs

  • Manual subscription management adds complexity
  • Debugging becomes harder than the React state model
  • React DevTools won't show changes in reactive state
  • Works well with small shared state, but not ideal for deeply nested trees unless abstracted

You can use this pattern to build custom global stores, reactive form fields, or live UIs like dashboards.

Example: Using reactive form state without setState

Here's the complete example where we define the reactive form state and use it in a form component without using React's useState or causing full component re-renders.

Define your reactive form state

const formState = {
  name: createReactiveValue(""),
  email: createReactiveValue(""),
};

Create a generic InputField component

function InputField({ label, state }) {
  const value = useReactiveValue(state);
  return (
    <div>
      <label>{label}</label>
      <input
        value={value}
        onChange={e => state.set(e.target.value)}
        style={{ display: 'block', marginBottom: '1rem' }}
      />
    </div>
  );
}

Build the form component

function ReactiveForm() {
  const handleSubmit = (e) => {
    e.preventDefault();
    alert(`Name: ${formState.name.get()}\nEmail: ${formState.email.get()}`);
  };

  return (
    <form onSubmit={handleSubmit}>
      <InputField
        label="Name"
        state={formState.name}
      />
      <InputField
        label="Email"
        state={formState.email}
      />
      <button type="submit">Submit</button>
    </form>
  );
}

Render in App

function App() {
  return (
    <div>
      <h2>Reactive Form Example</h2>
      <ReactiveForm />
    </div>
  );
}

Key takeaways

  • Each input field only listens to its own reactive value.
  • No component re-renders when other fields update.
  • The form uses formState.name.get() and formState.email.get() to access the current values at submission.
  • You completely avoid useState, useReducer, or any form library, while maintaining full control over rendering and state.

Advanced reactive form state with validation and error handling

Now that you've seen how to build a basic form with reactive values, let's level up and make this structure practical for real-world apps.

We'll build a form system that mirrors Formik-style logic, but in our fine-grained reactivity style — no component re-renders, no useState, and no external libraries.

Structure your form state like formik

We'll expand formState to hold:

  • values: reactive values
  • errors: reactive error messages
  • touched: reactive flags for blur tracking
  • validators: validation functions for each field
function createFormField(initialValue, validateFn) {
  return {
    value: createReactiveValue(initialValue),
    error: createReactiveValue(""),
    touched: createReactiveValue(false),
    validate: validateFn,
  };
}

Then build the form state:

const formState = {
  name: createFormField("", (v) =>
    v.trim() === "" ? "Name is required" : ""
  ),
  email: createFormField("", (v) =>
    /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v)
      ? ""
      : "Email is invalid"
  ),
};

Hook up field components with validation

function InputField({ label, field }) {
  const value = useReactiveValue(field.value);
  const error = useReactiveValue(field.error);
  const touched = useReactiveValue(field.touched);

  const handleChange = (e) => {
    const next = e.target.value;
    field.value.set(next);
    const validation = field.validate(next);
    field.error.set(validation);
  };

  const handleBlur = () => {
    field.touched.set(true);
    const validation = field.validate(field.value.get());
    field.error.set(validation);
  };

  return (
    <div style={{ marginBottom: "1rem" }}>
      <label>{label}</label>
      <input
        value={value}
        onChange={handleChange}
        onBlur={handleBlur}
        style={{ display: "block", width: "100%" }}
      />
      {touched && error && (
        <span style={{ color: "red", fontSize: "0.875rem" }}>{error}</span>
      )}
    </div>
  );
}

Handle form submission with validation

function ReactiveForm() {
  const getFormValues = () => {
    return {
      name: formState.name.value.get(),
      email: formState.email.value.get(),
    };
  };

  const validateForm = () => {
    let isValid = true;
    for (const key in formState) {
      const field = formState[key];
      const error = field.validate(field.value.get());
      field.error.set(error);
      field.touched.set(true);
      if (error) isValid = false;
    }
    return isValid;
  };

  const handleSubmit = (e) => {
    e.preventDefault();
    if (validateForm()) {
      const values = getFormValues();
      alert(`Form submitted:\nName: ${values.name}\nEmail: ${values.email}`);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <InputField
        label="Name"
        field={formState.name}
      />
      <InputField
        label="Email"
        field={formState.email}
      />
      <button type="submit">Submit</button>
    </form>
  );
}

Optional extras like reset and dirty tracking

You can add initialValue and track if a field has changed.

Update createFormField:

function createFormField(initialValue, validateFn) {
  const value = createReactiveValue(initialValue);
  return {
    value,
    initialValue,
    error: createReactiveValue(""),
    touched: createReactiveValue(false),
    dirty: createReactiveValue(false),
    validate: validateFn,
  };
}

Inside handleChange:

field.dirty.set(next !== field.initialValue);

To reset the form:

function resetForm() {
  for (const key in formState) {
    const field = formState[key];
    field.value.set(field.initialValue);
    field.error.set("");
    field.touched.set(false);
    field.dirty.set(false);
  }
}

Then you can add a reset button:

<button type="button" onClick={resetForm}>Reset</button>

When you should actually use fine-grained reactivity in real projects

There's no doubt this pattern is fun to build and helps you understand reactivity at a deeper level. But you're probably wondering…

Is this just a cool trick, or should I actually use it in production?

Let's break it down.

When fine-grained reactivity is a great fit

Ultra-high performance UI with lots of interactivity

If you're building dashboards, real-time apps, or admin interfaces with many inputs or live widgets — where every re-render matters — this approach is gold.

Example: A financial trading UI where dozens of inputs and charts update independently every few milliseconds. You don't want a change in one form field to re-render everything.

Embedded widgets or micro-frontend components

In complex platforms where different parts of the UI are isolated (think: micro frontends or embeddable components inside CMSs), reactive fields that work on their own without React context or prop drilling are a huge win.

Example: A comment box widget embedded inside a legacy CMS, where using useState or Redux feels too heavy.

Reactive engines that need observability

If you're building an in-house design tool, a spreadsheet engine, or a state-based game engine — you'll love the way each piece of state reacts independently. It behaves more like a dependency graph than a tree of React components.

Example: A custom spreadsheet that updates one cell at a time without recalculating or re-rendering the entire sheet.

Low-rerender requirements in large lists

Forms inside tables, especially dynamic editable grids, benefit hugely from this model.

Example: A user table with 1000 rows, each with inline editable fields. Updating a field shouldn’t re-render 999 others.

When you should probably not use it

Let's be real — this isn't a silver bullet.

Simple CRUD Apps

If you're just building a basic blog, contact form, or CMS backend — you're better off with useState, react-hook-form, or Formik. They're easier, more mature, and well-documented.

You don't need laser-sharp reactivity if your app rerenders 5 times per second and nobody notices.

Team projects with junior developers

If you're working in a team, introducing custom reactive primitives means extra learning curve, and it may confuse others unless your team is on the same page.

In these cases, it's better to lean on mainstream tools your coworkers already know.

Heavy server-side rendering or static generation

React Server Components, Next.js, and similar tools are still deeply coupled to the React tree and its rendering model. Fine-grained client reactivity may fight the rendering strategy unless carefully scoped.

It's amazing after hydration, but not always helpful during SSR.

Is fine-grained reactivity production-ready?

  • Yes — if you know why you're using it and your app benefits from it.
  • No — if it's just for fun or over-engineering a simple problem.

Think of it like this:

Project typeUse this pattern?
Real-time dashboards✅ Yes
Static marketing pages❌ No
Data-heavy grid UIs✅ Yes
Blog post editor❌ No
Embedded component SDK✅ Yes
Simple contact form❌ No

You don't need signals to write reactive code. With a bit of plumbing, React can behave much like Solid.js under the hood. Fine-grained updates aren't about magic — they're about controlling what changes and when.

For beginners, this introduces a new way of thinking about state — outside of the usual useState. For seasoned developers, this unlocks optimization strategies for real-time UIs and high-performance interfaces.

React gives us the building blocks. We just need to assemble them creatively.

Fine-grained reactivity offers an advanced approach to state management in React by focusing on minimal re-renders and efficient state updates. It works especially well in scenarios where performance is crucial, such as real-time applications or large, dynamic forms.

While this technique provides better control over the UI's behavior, it's important to recognize that it might not be necessary for simpler applications. For projects with basic form handling or static pages, using traditional useState or libraries like Formik may be easier to implement and maintain. Always weigh the benefits of fine-grained reactivity against the complexity it adds to your codebase, ensuring it's the right solution for your project's needs.

Stay Updated

This site is protected by reCAPTCHA and the GooglePrivacy Policy andTerms of Service apply.