← Blog

Signals aren't monads and that's ok

#reactivity #functional programming #Signalium #Solidjs #Haskell

Recently, I came across a new signals library, Signalium, which presents itself as “a complete, framework-agnostic replacement for React Hooks that works everywhere” and claims to “build on the best of Hooks, and leave behind the rest.” I’m always happy to see signals-based solutions becoming more mainstream in the broad React ecosystem, and I hope Signalium gains traction.

That said, I was left scratching my head after reading the intro page, “A Brief Manifesto”, which contrasts signals with React Hooks by claiming, “Signals are reactive monads, and Hooks are not.” I have no issue with Hooks not being monads—most people wouldn’t expect them to be—but signals aren’t either. So what’s going on here?

What a monad is

Let’s start with the definition. In functional programming, a monad is an abstraction defined by two key operations:

  • return :: a -> M a takes a value of type a and wraps it in a monadic context M a.
  • bind :: M a -> (a -> M b) -> M b takes a monadic value M a and a function a -> M b, chaining them into a new monadic value M b.

With these, monads allow chaining computations on wrapped values while preserving their monadic context. This context preservation allows handling side effects like state changes, I/O, or error propagation in a structured way without losing functional purity and referential transparency. Composing such monadic operations is how pure functional programming can be made useful in real-world applications.

So, characterizing signals as “reactive monads” might be an attempt to evoke this power and versatility. But signals don’t meet the full requirements of monads, and their reactivity relies on a runtime process that builds and mutates a dependency graph, not the self-contained structure monads provide.

How signals work

It may appear that signals have something similar to return. Functions like createSignal() in Solid and state() in Signalium seem to take a plain value and return a wrapped value, a reactive container with a getter for its latest value. But this is not quite accurate because what createSignal() returns is not a value with a self-contained reactive structure but an accessor to an external context that keeps track of the reactive graph.

Reactive derivations, with or without memoization, don’t qualify as bind, either. First, we don’t explicitly supply the reactive context Reactive a to createMemo() or compute(). The computation function we provide is not a -> Reactive b, but more like () -> b where accessing reactive values inside its scope causes side effects of updating the context outside it. Lastly, the return value is again an accessor to the same context behind the scenes.

Here’s a minimal example from Signalium doc’s “Computed and State” page:

const value = state(123);

const useCustomHook = computed(() => {
  const v = value.get();

  // do something...
});

The computed() call creates a reactive scope, automatically tracking value’s usage through the .get() method call. When value changes, the runtime marks the computation for useCustomHook as stale, and reevaluates it on read. By automatically propagating changes in reactive values, signals offer a composable and convenient way to manage state. But they’re still not monads, as they rely on a side-effect driven, externally managed context.

Signals with monads?

This is not to say signals-like reactivity cannot be implemented in a purely functional way using monads. Monads chain computations while preserving context, and this context can be signals’ reactive state. Let’s illustrate this with a Haskell example, modeled after a minimal JavaScript implementation from Ryan Carniato’s excellent blog post, “Building a Reactive Library from Scratch”. If you’re not familiar with Haskell, don’t worry too much about the code details—I don’t really know Haskell either, and I used an LLM to generate it for illustrative purposes.

Here’s the example code:

module Main where

import Control.Monad.State.Strict
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
import Data.Set (Set)
import qualified Data.Set as Set
import Control.Monad (forM_, unless)

-- | Reactive monad, wrapping StateT over IO for state and effects.
newtype Reactive a = Reactive (StateT Context IO a)
  deriving ( Functor, Applicative, Monad, MonadState Context)

instance MonadIO Reactive where
  liftIO io = Reactive (lift io)

-- | Reactive state context.
data Context = Context
  { nextId              :: Int
  , subscriptions       :: Map SignalId (Int, Set ComputationId)
  , dependencies        :: Map ComputationId ([SignalId], Reactive ())
  , activeSubscriptions :: Set ComputationId
  }

type SignalId = Int
type ComputationId = Int

-- | Runs a Reactive computation.
compute :: Reactive a -> IO a
compute (Reactive m) = evalStateT m (Context 0 Map.empty Map.empty Set.empty)

-- | Creates a signal with an initial value.
createSignal :: Int -> Reactive SignalId
createSignal initialValue = do
  s <- get
  let id = nextId s
  put s { nextId = id + 1, subscriptions = Map.insert id (initialValue, Set.empty) (subscriptions s) }
  return id

-- | Reads a signal's value.
readSignal :: SignalId -> Reactive Int
readSignal signalId = gets $ \s -> fst (subscriptions s Map.! signalId)

-- | Updates a signal and triggers effects.
writeSignal :: SignalId -> Int -> Reactive ()
writeSignal signalId newValue = do
  modify $ \s -> s { subscriptions = Map.adjust (\(_, subs) -> (newValue, subs)) signalId (subscriptions s) }
  modify $ \s -> s { activeSubscriptions = Set.empty }  -- Clear for new cycle
  dependencies <- gets dependencies
  mapM_ execute $ Map.toList dependencies
  where
    execute (computationId, (deps, computation))
      | signalId `elem` deps = do
          isRunning <- gets $ \s -> Set.member computationId (activeSubscriptions s)
          unless isRunning $ do
            modify $ \s -> s { activeSubscriptions = Set.insert computationId (activeSubscriptions s) }
            computation
      | otherwise = return ()

-- | Creates an effect that runs on signal changes.
createEffect :: [SignalId] -> Reactive () -> Reactive ()
createEffect signalIds computation = do
  s <- get
  let id = nextId s
  put s { nextId = id + 1 }
  modify $ \s -> s { dependencies = Map.insert id (signalIds, computation) (dependencies s) }
  modify $ \s -> s { subscriptions = foldr (\sid -> Map.adjust (\(val, subs) -> (val, Set.insert id subs)) sid) (subscriptions s) signalIds }
  computation

-- | Creates a memoized signal from a computation.
createMemo :: [SignalId] -> Reactive Int -> Reactive SignalId
createMemo signalIds computation = do
  signalId <- createSignal 0
  createEffect signalIds $ do
    value <- computation
    writeSignal signalId value
  return signalId

While this only implements a minimal push-based reactivity and is definitely not production-ready, it gets the idea across. The key takeaway here is that the reactive state Context is explicitly defined and managed. Operations like createSignal, readSignal, writeSignal, createEffect, and createMemo are all monadic, explicitly handling Context as their type signature shows.

To see how this works in practice, let’s look at a classic counter example:

main :: IO ()
main = compute $ do
  count <- createSignal 0
  doubleCount <- createMemo [count] $ do
    c <- readSignal count
    return (c * 2)

  -- Prints "Count: 0, Double: 0"
  createEffect [count, doubleCount] $ do
    c <- readSignal count
    dc <- readSignal doubleCount
    liftIO $ putStrLn $ "Count: " ++ show c ++ ", Double: " ++ show dc

  -- Prints "Count: 1, Double: 2"
  writeSignal count 1
  -- Prints "Count: 2, Double: 4"
  writeSignal count 2

You can find the full example code and run it on the Haskell playground.

Compare this to how signals manage reactivity in practice. They rely on a runtime mechanism that implicitly tracks dependencies through side effects, which is hidden from the user code. When a signal’s getter is called within a reactive scope (like computation for computed/memo or effect), the framework’s runtime records the dependency. When the signal’s value is updated, the runtime triggers updates in the affected scopes. This is fundamentally different from the monadic approach that fully encapsulate state, computations and side effects at the type level.

* * *

Ultimately, calling signals “reactive monads” does not work. Signals aren’t monads, and adding “reactive” doesn’t change that. Their reactivity hinges on runtime-managed dependency tracking and change propagation with an external state, not monadic structure. But signals don’t need to be monads to be cool—their superior performance and portability are reason enough to switch from Hooks. In fact, Signalium need not sell us on “reactive monads”—signals have already won the world, with every major modern web framework but React getting on board.