Skip to content

Core Concepts

Understanding the fundamental concepts that make RASK work.

Reactive State

State objects are automatically reactive. Any property access during render is tracked:

tsx
function TodoList() {
  const state = useState({
    todos: [],
    filter: "all",
  });

  const addTodo = (text) => {
    state.todos.push({ id: Date.now(), text, done: false });
  };

  return () => (
    <div>
      <input
        value={state.filter}
        onInput={(e) => (state.filter = e.target.value)}
      />
      <ul>
        {state.todos
          .filter(
            (todo) => state.filter === "all" || todo.text.includes(state.filter)
          )
          .map((todo) => (
            <li key={todo.id}>{todo.text}</li>
          ))}
      </ul>
    </div>
  );
}

How It Works

  1. Proxy-based tracking - State objects use JavaScript Proxies to track property access
  2. Automatic dependency tracking - When you access state.count during render, that dependency is recorded
  3. Efficient updates - Only components that access changed properties re-render
  4. Deep reactivity - Nested objects and arrays are automatically reactive

Children reconciles

Children passed to components behaves as you expect. They are used in the render scope to render the children.

tsx
function Header(props) {
  return <h1>{props.children}</h1>;
}

function Parent() {
  const state = useState({ count: 0 });

  return () => (
    <div>
      <Header>Count is {state.count}</Header>
      <button onClick={() => state.count++}>Update</button>
    </div>
  );
}

Props reconcile and are reactive

Props also behaves as you would expect in the render scope, but they are also reactive, meaning they can be used with useEffect or useDerived.

tsx
function Header(props) {
  const computed = useDerived({
    double: () => props.count * 2,
  });

  useEffect(() => console.log(props.count));

  return () => <h1>Count is {props.count}</h1>;
}

function Parent() {
  const state = useState({ count: 0 });

  return () => (
    <div>
      <Counter count={state.count} />
      <button onClick={() => state.count++}>Update</button>
    </div>
  );
}

The One Rule: Never Destructure

RASK has observable primitives: Never destructure reactive objects (state, props, context values, tasks). Destructuring extracts plain values and breaks reactivity.

Reactive objects are implemented using JavaScript Proxies. When you access a property during render (e.g., state.count), the proxy tracks that dependency. But when you destructure (const { count } = state), the destructuring happens during setup—before any tracking context exists. You get a plain value instead of a tracked property access.

This applies to:

  • useState() - Never destructure state objects
  • Props - Never destructure component props
  • useContext() - Never destructure context values
  • useAsync() - Never destructure async state objects
  • useView() - Never destructure view objects
  • useDerived() - Never destructure computed objects

Automatic Batching

RASK automatically batches state updates to minimize re-renders.

  • User interactions (clicks, inputs, keyboard, etc.) - State changes are batched and flushed synchronously at the end of the event
  • Other updates (setTimeout, fetch callbacks, etc.) - State changes are batched and flushed on the next microtask

Effects

Create side effects that automatically track dependencies:

tsx
function Timer() {
  const state = useState({ count: 0, log: [] });

  // Effect runs immediately and whenever state.count changes
  useEffect(() => {
    console.log("Count changed:", state.count);
    state.log.push(`Count: ${state.count}`);
  });

  return () => (
    <div>
      <p>Count: {state.count}</p>
      <button onClick={() => state.count++}>Increment</button>
      <ul>
        {state.log.map((entry, i) => (
          <li key={i}>{entry}</li>
        ))}
      </ul>
    </div>
  );
}

Effect Behavior

  • Runs immediately and synchronously on creation during setup
  • Automatically tracks reactive dependencies
  • Re-runs when dependencies change
  • Automatically cleaned up when component unmounts

Computed Values

Create derived values that cache results:

tsx
function ShoppingCart() {
  const state = useState({
    items: [
      { id: 1, name: "Apple", price: 1.5, quantity: 3 },
      { id: 2, name: "Banana", price: 0.8, quantity: 5 },
    ],
    taxRate: 0.2,
  });

  const computed = useDerived({
    subtotal: () =>
      state.items.reduce((sum, item) => sum + item.price * item.quantity, 0),
    tax: () => computed.subtotal * state.taxRate,
    total: () => computed.subtotal + computed.tax,
  });

  return () => (
    <div>
      <h2>Cart</h2>
      <ul>
        {state.items.map((item) => (
          <li key={item.id}>
            {item.name}: ${item.price} x {item.quantity}
          </li>
        ))}
      </ul>
      <p>Subtotal: ${computed.subtotal.toFixed(2)}</p>
      <p>Tax: ${computed.tax.toFixed(2)}</p>
      <p>
        <strong>Total: ${computed.total.toFixed(2)}</strong>
      </p>
    </div>
  );
}

Computed Benefits

  • Lazy evaluation - Only calculated when accessed
  • Automatic caching - Results cached until dependencies change
  • Composable - Computed properties can depend on other computed properties
  • Efficient - Only recomputes when dirty

Lifecycle Hooks

Manage component lifecycle:

tsx
function Example() {
  const state = useState({ time: Date.now() });

  // Runs after component mounts
  useMountEffect(() => {
    console.log("Component mounted!");
  });

  // Set up interval
  const interval = setInterval(() => {
    state.time = Date.now();
  }, 1000);

  // Clean up when component unmounts
  useCleanup(() => {
    clearInterval(interval);
  });

  return () => <div>{state.time}</div>;
}

Context API

Share state without prop drilling:

tsx
const ThemeContext = createContext<{ color: string }>();

function App() {
  const injectTheme = useInjectContext(ThemeContext);

  injectTheme({ color: "blue" });

  return () => <Child />;
}

function Child() {
  const theme = useContext(ThemeContext);

  return () => <div style={{ color: theme.color }}>Themed text</div>;
}

Lists and Keys

Use keys to maintain component identity:

tsx
function TodoList() {
  const state = useState({
    todos: [
      { id: 1, text: "Learn RASK" },
      { id: 2, text: "Build app" },
    ],
  });

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

Keys prevent component recreation when list order changes.

Composition with Views

Compose complex logic using useView:

tsx
function createAuthStore() {
  const state = useState({
    user: null,
    isAuthenticated: false,
    isLoading: false,
  });

  const login = async (username, password) => {
    state.isLoading = true;
    try {
      const user = await fetch("/api/login", {
        method: "POST",
        body: JSON.stringify({ username, password }),
      }).then((r) => r.json());
      state.user = user;
      state.isAuthenticated = true;
    } finally {
      state.isLoading = false;
    }
  };

  const logout = () => {
    state.user = null;
    state.isAuthenticated = false;
  };

  return useView(state, { login, logout });
}

function App() {
  const auth = createAuthStore();

  return () => (
    <div>
      {auth.isAuthenticated ? (
        <div>
          <p>Welcome, {auth.user.name}!</p>
          <button onClick={auth.logout}>Logout</button>
        </div>
      ) : (
        <button onClick={() => auth.login("user", "pass")}>Login</button>
      )}
    </div>
  );
}

This pattern is great for organizing complex business logic while keeping both state and methods accessible through a single object.

Released under the MIT License.