SIgnalium
A new take on reactivity in React
What do we mean by reactivity?

Basic Model: Output is the result of a pure function based on state
- Works great for initial render!
- But, rerendering the entire app each time would be expensive
- Also, no way to have "local" component state, async, or side effects
import './App.css'
export default function App({ count }) {
return (
<>
<h1>Vite + React</h1>
<div className="card">
<button>
count is {count}
</button>
</div>
</>
)
}
import { useState } from 'react'
import './App.css'
export default function App() {
const [count, setCount] = useState(0)
return (
<>
<h1>Vite + React</h1>
<div className="card">
<button onClick={() => setCount((count) => count + 1)}>
count is {count}
</button>
</div>
</>
)
}

Reactive Model: Hooks
- useState for local state
- useEffect for side effects
- etc etc.
import { useState, useEffect } from 'react'
import './App.css'
function useAutoCounter(ms) {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => setCount((c) => c + 1), ms);
return () => clearInterval(id);
}, [ms]);
return count;
}
export default function App() {
const count = useAutoCounter(1000);
return (
<>
<h1>Vite + React</h1>
<div className="card">
<button>
count is {count}
</button>
</div>
</>
)
}

Reactive Model: Hooks
- Reactivity also applies to functions, not just components
- Custom hooks can define and manage local state, and can setup and teardown lifecycle
Hooks are great!
- They decouple lifecycle logic from components!
- They allow you to manage async state and data loading!
- They allow you to compose reactive functionality throughout the app, not just in components!
why do so many people struggle with hooks?
export default function App({ orderDetails }) {
// Oops, should use `useCallback`!
const handleSubmit = (orderDetails) => {
post('/product/' + productId + '/buy', {
referrer,
orderDetails,
});
};
// Should also useMemo for this expensive operation
const processedOrder = process(orderDetails);
return (
<>
<button onClick={() => handleSubmit(processedOrder)}>
Send Order #{processedOrder.id}
</button>
</>
);
}
Example Issue 1: Hooks are called very frequently
- Hooks get called every time something in the component changes
- There is no way for an individual hook to rerun on its own
- This leads to many cases where you hooks rerun even though there's no possible way they could have changed
- Lot's of manual dependency validation needed, and that gets complicated fast
// Make a memoized version of process order, making it easier to
// use and compose in functions
function useProcessedOrder(orderDetails) {
return useMemo(() => process(orderDetails), [orderDetails]);
}
function useHandleSubmit(orderDetails) {
// Ooh! We can reuse our memoized hook here! Right?
// Wrong, this will lead to multiple calls to process
// even if the order details are the same
const processedOrder = useProcessedOrder(orderDetails);
return useCallback(() => handleSubmit(processedOrder), [processedOrder]);
}
export default function App({ orderDetails }) {
const handleSubmit = useHandleSubmit(orderDetails);
const processedOrder = useProcessedOrder(orderDetails);
return (
<>
<button onClick={() => handleSubmit(processedOrder)}>
Send Order #{processedOrder.id}
</button>
</>
);
}
Example Issue 2: Hook state cannot be reused
- Hooks are very composable by default
- But that composability is a double edged sword, because they are not reusing state
- If you have an expensive hook, and you call it 3 times in various places, it will run 3 times the first time
- Subsequent runs are memo initial ones
- All of them will also rerun if their inputs rerun
export default function Profile({ userId }) {
const { data: user, isLoading } = useQuery(
['user', userId],
() => fetchUser(userId),
{
enabled: !!userId,
}
);
return isLoading ? (
<p>Loading...</p>
) : (
<>
<UserAvatar user={user} />
<UserStats user={user} />
<UserPosts user={user} />
<UserFollowers user={user} />
<UserFollowing user={user} />
<UserSettings user={user} />
<UserNotifications user={user} />
<UserGroups user={user} />
<UserFeed user={user} />
</>
);
}
Example Issue 3: Hooks cannot be passed around
- In idiomatic hooks usage, there is no easy way to create state that is both local to a subtree and efficiently updates that subtree
- In this example, we load the user and pass it into a bunch of subcomponents
- If the user ever changes, then we need to rerender all of them
- Even with VDOM, this is expensive and causes bugs due to complex effects and state management
- Only general solution is a context, which is a lot of boilerplate
Root cause?
Hooks are non-monadic
export default async function fetchUserProfileAndPosts(username) {
const userRes = await fetchJSON(
`/api/users/${username}`
);
const userProfile = await fetchJSON(
`/api/users/${userDetails.id}/profile`
);
const userPosts = await fetchJSON(
`/api/users/${userDetails.id}/posts`
);
return { userDetails, userProfile, userPosts };
}
What's a monad?
- Monads are good at sequencing things.
- Consider promises
- Promises are monad-like (technically not a monad for reasons but very close. Futures are true monads.)
- Promises store the current state while async happens
- Callstack
- Variables
- Program counter (current line)
- When async returns, you restore all of that state and go back to where you were before
export default async function fetchUserProfileAndPosts(username) {
const userDetails = await fetchJSON(
`/api/users/${username}`
);
const userProfile = await fetchJSON(
`/api/users/${userDetails.id}/profile`
);
const userPosts = await fetchJSON(
`/api/users/${userDetails.id}/posts`
);
return { userDetails, userProfile, userPosts };
}
Imagine async without promises
- One possibility is that we simply re-execute the entire program each time
- One possibility is that we simply re-execute the entire program each time
- Just make sure that if a function has already been called before, you return the value it had previously
This is how hooks work!
export default async function fetchUserProfileAndPosts(username) {
const {
data: userDetails,
isLoading: userDetailsLoading,
} = await useUserQuery(username);
const {
data: userProfile,
isLoading: userProfileLoading,
} = await useUserProfile(userDetails?.id);
const {
data: userPosts,
isLoading: userPostsLoading,
} = await useUserPosts(userDetails?.id);
const isLoading =
userDetailsLoading ||
userProfileLoading ||
userPostsLoading;
return {
userDetails,
userProfile,
userPosts,
isLoading,
};
}
Imagine async without promises
- One possibility is that we simply re-execute the entire program each time
- One possibility is that we simply re-execute the entire program each time
- Just make sure that if a function has already been called before, you return the value it had previously
This is how hooks work!
We need a reactivity monad
Ideal design:
- It should only re-execute when absolutely necessary
- It should be something you can pass around and reference in multiple locations
- It should still have the benefits of hooks
- Composability
- Lifecycle management
- Easy to reuse
Signals!
const lineItems = state([
{
id: 1,
name: 'Line Item 1',
price: 100,
quantity: 1,
},
{
id: 2,
name: 'Line Item 2',
price: 200,
quantity: 2,
},
]);
const useTotal = computed(() => {
return lineItems.reduce((total, item) =>
total + item.price * item.quantity,
0
);
});
export function ReceiptTotal() {
return (
<div>
<p>Total: {useTotal()}</p>
</div>
)
}
Basic Signals
- Signals propagate from the updated state
- This means that components don't update unless signal state changed
- Can be used like normal hooks anywhere
const useUserDetails = asyncComputed(async (username) => {
return useFetchJSON(`/api/users/${username}`);
});
const useUserProfile = asyncComputed(async (userId) => {
return useFetchJSON(`/api/users/${userId}/profile`);
});
const useUserPosts = asyncComputed(async (userId) => {
return useFetchJSON(`/api/users/${userId}/posts`);
});
export const useUserProfileAndPosts = asyncComputed(
async (username) => {
const userDetails = useUserQuery(username).await();
const userProfile = useUserProfile(userDetails.id).await();
const userPosts = useUserPosts(userDetails.id).await();
return { userDetails, userProfile, userPosts };
}
);
export const useUserProfileAndPostsWithDefaults = computed((username) => {
const { data, isPending } = useUserProfileAndPosts(username);
if (isPending) {
return {
userDetails: null,
userProfile: null,
userPosts: null,
};
}
return data;
});
Async Signals!
- Signals have async built in
- Can await state internally for composing
- Automatically recompute when values change
- Can be consumed using `isPending` state externally (like React Query!)
const useAutoCounter = subscription((state, ms) => {
const id = setInterval(() => state.set(state.get() + 1), ms);
return () => clearInterval(id);
}, { initValue: 0 })
export default function App() {
const count = useAutoCounter(1000);
return (
<>
<h1>Vite + React</h1>
<div className="card">
<button>
count is {count}
</button>
</div>
</>
)
}
Subscriptions!
- Self-contained effects within the graph
- Externally, appears like a normal "sync" node
- Internally, manages its own lifecycle
- Subscribes when used by other things, unsubscribes when no more users
- Only it can manage its own state
Fin
deck
By pzuraq
deck
- 66