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