skip to content
Avatar Logo
Custom State Management

Custom State Management

/ 6 min read

Last Updated:

Hi guys.

Today we are going to build our custom state management inspired by SolidJS signal. It is very similar to React useState.

Our Structure

We’ll build on the structure from our last post. Feel free to fork it, or start from scratch with a similar setup. Your final result should look like this:

  • Directorysrc
    • Directoryutils
      • jsx-runtime.mjs
      • render-jsx.mjs
    • App.mjs
    • index.html
    • index.mjs
  • .babelrc
  • package.json

Intro to Signals

According to the official documentation.

State management is the process of managing the state of an application. This involves storing and updating data, as well as responding to the changes in it.

With Solid, state management is handled through signals and subscribers. Signals are used to store and update data, while subscribers are used to respond to changes in the data.

So, signals allow you to save data in a store and update it. When the data changes, it triggers the subscribers. Let’s adjust our App component a bit.

./src/App.mjs
import { createSignal } from "./utils/signal.mjs"
function App() {
const [count, setCount] = createSignal(0)
const increment = () => {
setCount((prev) => prev + 1)
}
return (
<div>
<span>Count: {count()}</span>
<button type="button" onClick={increment}>
Increment
</button>
</div>
)
}
export default App

We have a simple component with a counter that increments when you click the button.

Currently, we’re getting an error because the createSignal function isn’t defined yet.

Custom createSignal Function

Alright, it’s time to define our own state management. First, let’s create a new file called signal.mjs and place it in the ./src/utils folder.

  • Directorysrc
    • Directoryutils
      • jsx-runtime.mjs
      • render-jsx.mjs
      • signal.mjs new file!
    • App.mjs
    • index.html
    • index.mjs
  • .babelrc
  • package.json

Next, let’s define the createSignal function.

./src/utils/signal.mjs
let state
function createSignal(initialValue) {
state = state || initialValue
const getState = () => {
return state
}
const setState = (newValue) => {
const resolvedState =
newValue instanceof Function ? newValue(state) : newValue
if (resolvedState === state) return
state = resolvedState
}
return [getState, setState]
}
export { createSignal }

So, we’ve defined a global state and the createSignal function. This function takes an initial value as an argument and returns a pair of functions: a getter and a setter, just like SolidJS’s createSignal function. It also skips updating if the new value is the same as the current state.

We’ve fixed the error, but clicking the Increment button still doesn’t do anything. The reason is that we’re not re-rendering the component after updating the state.

Re-render Function

We need to create a new file called render.mjs in the ./src/utils/ folder.

  • Directorysrc
    • Directoryutils
      • jsx-runtime.mjs
      • render-jsx.mjs
      • signal.mjs
      • render.mjs new file!
    • App.mjs
    • index.html
    • index.mjs
  • .babelrc
  • package.json

Next, we’ll define a render function that will subscribe and re-render the component every time the state changes.

import { renderJSX } from "./render-jsx.mjs"
import { subscribeGlobal } from "./signal.mjs"
function renderApp(App, container) {
container.innerHTML = ""
renderJSX(App(), container)
}
function render(App, container) {
renderApp(App, container)
subscribeGlobal(() => {
renderApp(App, container)
})
}
export { render }

Let’s define the subscribeGlobal function in signal.mjs

./src/utils/signal.mjs
let state
let isBatching = false
const globalSubscribers = new Set()
function flushBatch() {
globalSubscribers.forEach((subscriber) => subscriber())
isBatching = false
}
function notifyGlobalSubscribers() {
if (!isBatching) {
isBatching = true
queueMicrotask(flushBatch)
}
}
function createSignal(initialValue) {
state = state || initialValue
const getState = () => {
return state
}
const setState = (newValue) => {
const resolvedState =
newValue instanceof Function ? newValue(state) : newValue
if (resolvedState === state) return
state = resolvedState
notifyGlobalSubscribers()
}
return [getState, setState]
}
function subscribeGlobal(subscriber) {
globalSubscribers.add(subscriber)
return () => globalSubscribers.delete(subscriber)
}
export { createSignal, subscribeGlobal }

Let’s review what we’ve added. We implemented the subscribeGlobal function, which adds callbacks to the globalSubscribers variable and triggers each subscriber whenever the state updates. We’ll use subscribeGlobal to re-render our DOM.

We’re also utilizing queueMicrotask and isBatching to prevent multiple executions of flushBatch.

The microtask is a short function which will run after the current task has completed its work and when there is no other code waiting to be run before control of the execution context is returned to the browser’s event loop.

Now, when we run the project and click the increment button, the number increments as expected. Everything seems to be working, but it currently handles only one state. Let’s add another one.

./src/App.mjs
import { createSignal } from "./utils/signal.mjs"
function App() {
const [count, setCount] = createSignal(0)
const [name, setName] = createSignal(null)
const increment = () => {
setCount((prev) => prev + 1)
}
return (
<div>
<span>Count: {count()}</span>
<button type="button" onClick={increment}>
Increment
</button>
<div>
<span>State: {name()}</span>
<button type="button" onClick={() => setName(`Another state - ${count()}`)}>
Update state
</button>
</div>
</div>
)
}
export default App

There’s an issue: the second signal is overwriting the state of the previous one because both createSignal functions are using the same state.

Multiple States

To fix this, we need to extend the createSignal function to manage data in a separate state for each function. To prevent overwriting, we’ll use an index to store data at the correct position in the state. Let’s adjust our function accordingly.

./src/utils/signal.mjs
let state
const globalState = new Map()
let isBatching = false
let globalStatePosition = 0
const globalSubscribers = new Set()
function flushBatch() {
globalStatePosition = 0
globalSubscribers.forEach((subscriber) => subscriber())
isBatching = false
}
function notifyGlobalSubscribers() {
if (!isBatching) {
isBatching = true
queueMicrotask(flushBatch)
}
}
function createSignal(initialValue) {
state = state || initialValue
const statePosition = globalStatePosition
globalState.set(statePosition, globalState.get(statePosition) || initialValue)
let value = globalState.get(statePosition)
const getState = () => {
return state
}
const setState = (newValue) => {
const resolvedState =
newValue instanceof Function ? newValue(state) : newValue
if (resolvedState === state) return
state = resolvedState
globalState.set(statePosition, state)
notifyGlobalSubscribers()
}
globalStatePosition++
return [getState, setState]
}
function subscribeGlobal(subscriber) {
globalSubscribers.add(subscriber)
return () => globalSubscribers.delete(subscriber)
}
export { createSignal, subscribeGlobal }

So, what we have is that each time we call the createSignal function, it increments globalStatePosition by 1. This helps us assign a unique position in the state for each createSignal instance. We also reset the position to 0 whenever the page re-renders. Now everything works because each state is independent and doesn’t overwrite others.

Conclusion

We’ve built a simple state management system for components, inspired by the signal mechanism from SolidJS. This allows us to dynamically update data and render it on the page.

However, our custom state management has performance limitations. It needs improvements, such as re-rendering only specific components instead of the entire page and handling complex data. For now, it’s not suitable for production use.

Thank you for reading 😊