Chapter 4: Solid.js - React’s Cooler Younger Sibling

JSX Without the Baggage

“I actually like JSX,” you say sheepishly, like you’re confessing to a crime.

That’s fine! JSX isn’t the problem. JSX is great. The problem is what React does with it.

Solid looked at React and said: “What if we kept the JSX, but fixed literally everything else?”

import { createSignal } from 'solid-js'

function Counter() {
  const [count, setCount] = createSignal(0)

  return (
    <button onClick={() => setCount(count() + 1)}>
      Count: {count()}
    </button>
  )
}

Look familiar? It should. It’s JSX. It’s a component function. It even has something that looks like useState.

But here’s the twist: this function runs once. Not on every render. Just once. Ever.

Your React brain just short-circuited.

Fine-Grained Reactivity Explained

Remember React’s model?

  1. State changes
  2. Component function re-runs
  3. New JSX tree is created
  4. Virtual DOM diffing happens
  5. Real DOM updates

Now here’s Solid’s model:

  1. State changes
  2. DOM updates

That’s it. No re-running. No diffing. No virtual DOM.

“But HOW?!”

Solid’s compiler turns your JSX into reactive primitives. The count() call isn’t just reading a value—it’s creating a dependency. When setCount runs, only the specific text node that depends on count() updates.

function Counter() {
  const [count, setCount] = createSignal(0)

  console.log('Component function runs!')

  return (
    <button onClick={() => setCount(count() + 1)}>
      Count: {count()}
    </button>
  )
}

That console.log runs once. Click the button a thousand times. Still just one log.

The component function is initialization, not render.

Mind. Blown.

No More useCallback, No More useMemo

In React, you need to memoize everything:

function TodoList({ todos }) {
  const [filter, setFilter] = useState('all')

  // Memoize the filtered list
  const filteredTodos = useMemo(() => {
    return todos.filter(todo => {
      if (filter === 'active') return !todo.done
      if (filter === 'completed') return todo.done
      return true
    })
  }, [todos, filter])

  // Memoize the handler
  const handleFilterChange = useCallback((newFilter) => {
    setFilter(newFilter)
  }, [])

  return (
    <div>
      <FilterButtons onChange={handleFilterChange} />
      <TodoItems items={filteredTodos} />
    </div>
  )
}

In Solid, you just… write the code:

function TodoList(props) {
  const [filter, setFilter] = createSignal('all')

  const filteredTodos = () => {
    return props.todos.filter(todo => {
      if (filter() === 'active') return !todo.done
      if (filter() === 'completed') return todo.done
      return true
    })
  }

  const handleFilterChange = (newFilter) => {
    setFilter(newFilter)
  }

  return (
    <div>
      <FilterButtons onChange={handleFilterChange} />
      <TodoItems items={filteredTodos()} />
    </div>
  )
}

No useMemo. No useCallback. No dependency arrays. The filtered function only re-runs when props.todos or filter() changes because Solid tracks the dependencies automatically.

Functions are just functions. They don’t need optimization hints.

Props Don’t Change (In a Good Way)

In React, props are new objects on every render:

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

  return (
    <Child
      user= // New object every render!
      onUpdate={() => setCount(c => c + 1)} // New function every render!
    />
  )
}

Cue the React.memo, useCallback, and useMemo dance.

In Solid, props are proxy objects with getters:

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

  return (
    <Child
      user=
      onUpdate={() => setCount(count() + 1)}
    />
  )
}

function Child(props) {
  // props.user is the same object
  // props.onUpdate is the same function
  // But accessing props.user.name creates a dependency
}

Props are stable. They don’t change. What changes is the reactive values inside them. No memoization needed.

createEffect: useEffect Done Right

React’s useEffect is a footgun:

useEffect(() => {
  console.log('Count changed:', count)
}, [count]) // Forget this array? Infinite loop.

Solid’s createEffect just works:

createEffect(() => {
  console.log('Count changed:', count())
})

No dependency array. It tracks dependencies automatically by seeing what you call. When count() changes, the effect re-runs.

Cleanup

React:

useEffect(() => {
  const timer = setInterval(() => {
    console.log('tick')
  }, 1000)

  return () => clearInterval(timer)
}, []) // Empty array for "mount only"

Solid:

createEffect(() => {
  const timer = setInterval(() => {
    console.log('tick')
  }, 1000)

  onCleanup(() => clearInterval(timer))
})

The onCleanup function registers cleanup code. When the effect re-runs or the component unmounts, cleanup runs first. No weird return function. No dependency array confusion.

Control Flow: No More && and ?:

React’s conditional rendering:

function UserGreeting({ user, loading, error }) {
  return (
    <div>
      {loading && <Spinner />}
      {error && <Error message={error} />}
      {!loading && !error && user && (
        <h1>Hello, {user.name}</h1>
      )}
    </div>
  )
}

Every branch gets evaluated on every render. Even the ones that aren’t shown.

Solid has built-in control flow components:

import { Show, Switch, Match } from 'solid-js'

function UserGreeting(props) {
  return (
    <div>
      <Show when={props.loading}>
        <Spinner />
      </Show>

      <Show when={props.error}>
        <Error message={props.error} />
      </Show>

      <Show when={!props.loading && !props.error && props.user}>
        <h1>Hello, {props.user.name}</h1>
      </Show>
    </div>
  )
}

Or better yet, with Switch/Match:

function UserGreeting(props) {
  return (
    <Switch>
      <Match when={props.loading}>
        <Spinner />
      </Match>

      <Match when={props.error}>
        <Error message={props.error} />
      </Match>

      <Match when={props.user}>
        <h1>Hello, {props.user.name}</h1>
      </Match>
    </Switch>
  )
}

Only the active branch exists in the DOM. Only the active branch is reactive. No wasted computations.

For Loops That Don’t Destroy Everything

React’s list rendering:

function TodoList({ todos }) {
  return (
    <ul>
      {todos.map(todo => (
        <TodoItem key={todo.id} todo={todo} />
      ))}
    </ul>
  )
}

When the todos array changes, React diffs everything, uses keys to match components, and tries to minimize DOM updates.

Solid’s For component:

import { For } from 'solid-js'

function TodoList(props) {
  return (
    <ul>
      <For each={props.todos}>
        {(todo) => <TodoItem todo={todo} />}
      </For>
    </ul>
  )
}

The For component tracks items by reference. When an item changes, only that item updates. When items are reordered, nothing re-renders—the DOM nodes just move. No keys needed (though you can use them for by-value comparison).

Index Tracking

<For each={props.todos}>
  {(todo, index) => (
    <li>
      {index() + 1}. {todo.name}
    </li>
  )}
</For>

The index is a signal. It only updates when the item’s position changes. Beautiful.

Stores: Nested Reactivity

React’s useState is flat:

const [user, setUser] = useState({
  name: 'Alice',
  profile: {
    age: 30,
    email: 'alice@example.com'
  }
})

// To update nested state:
setUser(u => ({
  ...u,
  profile: {
    ...u.profile,
    age: 31
  }
}))

Immer helps, but it’s still ceremony.

Solid’s createStore provides nested reactivity:

import { createStore } from 'solid-js/store'

const [user, setUser] = createStore({
  name: 'Alice',
  profile: {
    age: 30,
    email: 'alice@example.com'
  }
})

// To update nested state:
setUser('profile', 'age', 31)

// Or with a function:
setUser('profile', 'age', age => age + 1)

// Or multiple levels:
setUser('profile', { age: 31, email: 'new@example.com' })

Any component that reads user.profile.age will update when it changes. Components that read user.name won’t. Fine-grained reactivity all the way down.

Migration from React: Keep the Syntax, Lose the Pain

The migration is surprisingly smooth because the syntax is so similar.

Converting useState

React:

const [count, setCount] = useState(0)
setCount(count + 1)

Solid:

const [count, setCount] = createSignal(0)
setCount(count() + 1)

Just add parentheses to read the value. That’s it.

Converting useEffect

React:

useEffect(() => {
  document.title = `Count: ${count}`
}, [count])

Solid:

createEffect(() => {
  document.title = `Count: ${count()}`
})

Remove the dependency array, add parentheses to the signal.

Converting useMemo

React:

const doubled = useMemo(() => count * 2, [count])

Solid:

const doubled = () => count() * 2

Just a function. Or use createMemo if the computation is expensive:

const doubled = createMemo(() => count() * 2)

Converting useCallback

React:

const increment = useCallback(() => {
  setCount(c => c + 1)
}, [])

Solid:

const increment = () => {
  setCount(count() + 1)
}

Just a function. No hook needed.

Converting Context

React:

const ThemeContext = createContext()

function App() {
  const [theme, setTheme] = useState('dark')

  return (
    <ThemeContext.Provider value=>
      <Dashboard />
    </ThemeContext.Provider>
  )
}

function Dashboard() {
  const { theme } = useContext(ThemeContext)
  return <div className={theme}>...</div>
}

Solid:

import { createContext, useContext } from 'solid-js'

const ThemeContext = createContext()

function App() {
  const [theme, setTheme] = createSignal('dark')

  return (
    <ThemeContext.Provider value={[theme, setTheme]}>
      <Dashboard />
    </ThemeContext.Provider>
  )
}

function Dashboard() {
  const [theme] = useContext(ThemeContext)
  return <div classList=>...</div>
}

Almost identical. The API is intentionally similar.

The Performance Story

Remember when you had to optimize everything in React?

// Preventing unnecessary re-renders
export default React.memo(Component)

// Memoizing computed values
const value = useMemo(() => expensive(data), [data])

// Memoizing callbacks
const handler = useCallback(() => doThing(), [])

// Splitting code to reduce bundle
const Component = React.lazy(() => import('./Component'))

In Solid, components don’t re-render. Ever. There’s nothing to optimize. You write code and it’s fast by default.

Benchmarks (js-framework-benchmark):

When to Use Solid

Solid is perfect for:

Solid might not be ideal for:

The Learning Curve

If you know React:

The syntax similarity is a feature. You can be productive immediately. The conceptual differences make you more productive over time.

Real Talk: Solid vs React

React says: “Components are functions that return JSX and re-run when state changes.”

Solid says: “Components are functions that return JSX and run once to set up reactive bindings.”

Same syntax. Opposite execution models.

React optimizes by minimizing re-renders through diffing. Solid optimizes by never re-rendering at all.

One approach needs constant vigilance and optimization. The other is fast by default.

Choose accordingly.


“I switched from React to Solid and my app got faster, my code got clearer, and I stopped having stress dreams about dependency arrays.” — A Developer Who Stopped Re-Rendering

Up Next: Chapter 5: Angular - The Framework With Opinions (And a Therapist)