Skip to main content

Examples

Pagination

Cursor-based infinite scroll with filter-aware resets, IntersectionObserver effects, and cross-module dependencies.

Try it

Loading example…

Scroll the list to trigger infinite loading. Change filters or search to reset to page 1. Watch the state inspector to see constraints fire.

How it works

Two modules – filters for search/sort/category and list for items and pagination state – compose into a system where filter changes automatically reset the list.

  1. Facts items, cursor, hasMore, isLoadingMore, and scrollNearBottom (set by IntersectionObserver)
  2. Derivations totalLoaded and isEmpty for UI state
  3. Constraints loadMore fires when the scroll sentinel is visible and more pages exist; filterChanged resets the list when any filter changes
  4. Effects observeScroll uses IntersectionObserver to detect when the sentinel enters the viewport, with proper cleanup on disconnect

Summary

What: Cursor-based pagination with infinite scroll, search, category filters, and sort – all with automatic reset on filter change.

How: The loadMore constraint gates on three conditions (hasMore, not loading, scroll near bottom). The filterChanged constraint uses a hash to detect filter changes and reset the list.

Why it works: Directive’s constraint system naturally prevents duplicate fetches (the three-condition gate) and handles filter resets declaratively. The IntersectionObserver effect with cleanup eliminates manual scroll listener management.

Source code

/**
 * Pagination & Infinite Scroll — Directive Modules
 *
 * Two modules: `filters` owns search/sort/category,
 * `list` owns items and pagination state with crossModuleDeps.
 *
 * Constraints:
 * - loadMore: appends next page when scrollNearBottom
 * - filterChanged: resets and re-fetches when filters change
 *
 * Effects:
 * - observeScroll: IntersectionObserver on sentinel element
 */

import { createModule, createSystem, t, type ModuleSchema } from "@directive-run/core";
import {loggingPlugin, devtoolsPlugin } from "@directive-run/core/plugins";
import { fetchPage, type ListItem } from "./mock-api.js";

// ============================================================================
// Filters Module
// ============================================================================

export const filtersSchema = {
  facts: {
    search: t.string(),
    sortBy: t.string<"newest" | "oldest" | "title">(),
    category: t.string(),
  },
  events: {
    setSearch: { value: t.string() },
    setSortBy: { value: t.string() },
    setCategory: { value: t.string() },
  },
} satisfies ModuleSchema;

export const filtersModule = createModule("filters", {
  schema: filtersSchema,

  init: (facts) => {
    facts.search = "";
    facts.sortBy = "newest";
    facts.category = "all";
  },

  events: {
    setSearch: (facts, { value }) => {
      facts.search = value;
    },
    setSortBy: (facts, { value }) => {
      facts.sortBy = value;
    },
    setCategory: (facts, { value }) => {
      facts.category = value;
    },
  },
});

// ============================================================================
// List Module
// ============================================================================

export const listSchema = {
  facts: {
    items: t.object<ListItem[]>(),
    cursor: t.string(),
    hasMore: t.boolean(),
    isLoadingMore: t.boolean(),
    scrollNearBottom: t.boolean(),
    lastFilterHash: t.string(),
  },
  derivations: {
    totalLoaded: t.number(),
    isEmpty: t.boolean(),
  },
  events: {
    setScrollNearBottom: { value: t.boolean() },
  },
  requirements: {
    LOAD_PAGE: {
      cursor: t.string(),
      search: t.string(),
      sortBy: t.string(),
      category: t.string(),
    },
    RESET_AND_LOAD: {
      search: t.string(),
      sortBy: t.string(),
      category: t.string(),
    },
  },
} satisfies ModuleSchema;

export const listModule = createModule("list", {
  schema: listSchema,

  crossModuleDeps: { filters: filtersSchema },

  init: (facts) => {
    facts.items = [];
    facts.cursor = "";
    facts.hasMore = true;
    facts.isLoadingMore = false;
    facts.scrollNearBottom = false;
    facts.lastFilterHash = "";
  },

  // ============================================================================
  // Derivations
  // ============================================================================

  derive: {
    totalLoaded: (facts) => facts.self.items.length,
    isEmpty: (facts) => facts.self.items.length === 0 && !facts.self.hasMore,
  },

  // ============================================================================
  // Events
  // ============================================================================

  events: {
    setScrollNearBottom: (facts, { value }) => {
      facts.scrollNearBottom = value;
    },
  },

  // ============================================================================
  // Constraints
  // ============================================================================

  constraints: {
    loadMore: {
      when: (facts) => {
        return (
          facts.self.hasMore &&
          !facts.self.isLoadingMore &&
          facts.self.scrollNearBottom
        );
      },
      require: (facts) => ({
        type: "LOAD_PAGE",
        cursor: facts.self.cursor,
        search: facts.filters.search,
        sortBy: facts.filters.sortBy,
        category: facts.filters.category,
      }),
    },

    filterChanged: {
      when: (facts) => {
        const hash = `${facts.filters.search}|${facts.filters.sortBy}|${facts.filters.category}`;

        return hash !== facts.self.lastFilterHash;
      },
      require: (facts) => ({
        type: "RESET_AND_LOAD",
        search: facts.filters.search,
        sortBy: facts.filters.sortBy,
        category: facts.filters.category,
      }),
    },
  },

  // ============================================================================
  // Resolvers
  // ============================================================================

  resolvers: {
    loadPage: {
      requirement: "LOAD_PAGE",
      resolve: async (req, context) => {
        context.facts.isLoadingMore = true;

        try {
          const data = await fetchPage(req.cursor, 20, {
            search: req.search,
            sortBy: req.sortBy,
            category: req.category,
          });

          context.facts.items = [...context.facts.items, ...data.items];
          context.facts.cursor = data.nextCursor;
          context.facts.hasMore = data.hasMore;
        } finally {
          context.facts.isLoadingMore = false;
        }
      },
    },

    resetAndLoad: {
      requirement: "RESET_AND_LOAD",
      resolve: async (req, context) => {
        const hash = `${req.search}|${req.sortBy}|${req.category}`;

        context.facts.items = [];
        context.facts.cursor = "";
        context.facts.hasMore = true;
        context.facts.isLoadingMore = true;
        context.facts.lastFilterHash = hash;

        try {
          const data = await fetchPage("", 20, {
            search: req.search,
            sortBy: req.sortBy,
            category: req.category,
          });

          context.facts.items = data.items;
          context.facts.cursor = data.nextCursor;
          context.facts.hasMore = data.hasMore;
        } finally {
          context.facts.isLoadingMore = false;
        }
      },
    },
  },

  // ============================================================================
  // Effects
  // ============================================================================

  effects: {
    observeScroll: {
      run: (facts) => {
        const sentinel = document.getElementById("pg-scroll-sentinel");
        if (!sentinel) {
          return;
        }

        const observer = new IntersectionObserver(
          ([entry]) => {
            facts.self.scrollNearBottom = entry.isIntersecting;
          },
          { rootMargin: "200px" },
        );
        observer.observe(sentinel);

        return () => observer.disconnect();
      },
    },
  },
});

// ============================================================================
// System
// ============================================================================

export const system = createSystem({
  modules: { filters: filtersModule, list: listModule },
  plugins: [loggingPlugin()],
});

We care about your data. We'll never share your email.

Powered by Directive. This signup uses a Directive module with facts, derivations, constraints, and resolvers – zero useState, zero useEffect. Read how it works