Start typing to search packages!
paginationhandler
By @biotoxin495
Roblox
Mirrored from WallyPaginationHandler — Framework-independent pagination and virtualization utility for Roblox
PaginationHandler, a framework-independent data processing, pagination, and index-window virtualization utility for Roblox and Luau.
Building a paginated interface usually means hand-rolling search, filtering, sorting, page-size math, stable item identity, and index-window virtualization by hand, and re-doing it for every new list, grid, or catalog in a project.
PaginationHandler handles the data side of that for you. It manages the state behind paginated interfaces — searching, filtering, sorting, page navigation, stable item identity, batching, and virtualized view ranges — while leaving the actual UI entirely up to your project. It does not create a particular interface, require a ScrollingFrame, control page buttons, or assume an item template structure.
Use it with ordinary Roblox Instance UI, React Luau, Fusion, another UI framework, or no renderer at all.
Quick Example
local PaginationHandler = require(ReplicatedStorage:WaitForChild("PaginationHandler"))
local items = {
{ Id = "iron-sword", Name = "Iron Sword", Rarity = 1, Price = 100 },
{ Id = "gold-sword", Name = "Gold Sword", Rarity = 3, Price = 500 },
{ Id = "health-potion", Name = "Health Potion", Rarity = 2, Price = 75 },
}
local pagination = PaginationHandler.new({
ItemsPerPage = 12,
GetItemKey = function(item)
return item.Id
end,
SearchPredicate = function(item, query)
return string.find(string.lower(item.Name), query, 1, true) ~= nil
end,
})
pagination.ViewChanged:Connect(function(entries, context)
print(`Page {context.State.CurrentPage} of {context.State.PageCount}`)
for _, entry in entries do
print(entry.Key, entry.Item.Name)
end
end)
pagination:SetData(items)
ViewChanged receives the complete active page when virtualization is disabled. When virtualization is enabled, it receives only the active virtual window and its overscan.
🚀 Features
- Paginate any array-based dataset
- Search with a custom predicate and query normalizer
- Apply built-in, selector-based, or predicate-based filters
- Combine filters using
AllorAnybehavior - Apply multiple ordered sort rules, selectors, or custom comparators
- Automatically size pages from a
UIGridLayoutorUIListLayout - Preserve the current page or first visible item when data changes
- Navigate directly or with previous/next/first/last helpers
- Change the page size at runtime
- Batch multiple mutations into one processing pass
- Assign stable keys for rendering and item lookup
- Observe processed, page, view, render, and general state changes
- Reconcile optional renderer callbacks by stable item key
- Virtualize an index window within the active page
- Read state and data without exposing internal arrays
- Use strict Luau types throughout the public API
- No external dependencies
🛠️ Installation
Add PaginationHandler as a ModuleScript somewhere accessible to the code using it, such as ReplicatedStorage:
ReplicatedStorage
└── PaginationHandler
Then require it:
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local PaginationHandler = require(ReplicatedStorage:WaitForChild("PaginationHandler"))
📖 Basic Usage
Create a handler by supplying an optional configuration table.
local pagination = PaginationHandler.new(config)
Every field is optional. Provide data either through InitialData at construction or with SetData afterward:
local pagination = PaginationHandler.new({
ItemsPerPage = 20,
InitialData = items,
})
When the handler is no longer needed, call Destroy:
pagination:Destroy()
Reading the Active Page
Rendering callbacks are optional. You can read the current page whenever your own UI needs it:
for _, item in pagination:GetCurrentPageItems() do
print(item.Name)
end
Use entries when you also need identity and position metadata:
for _, entry in pagination:GetCurrentPageEntries() do
print(entry.Key)
print(entry.SourceIndex)
print(entry.ProcessedIndex)
print(entry.PageIndex)
end
Page Navigation
pagination:SetPage(3)
pagination:NextPage()
pagination:PreviousPage()
pagination:FirstPage()
pagination:LastPage()
Navigation methods return true when the active page changed and false when it did not.
PreviousButton.Activated:Connect(function()
pagination:PreviousPage()
end)
NextButton.Activated:Connect(function()
pagination:NextPage()
end)
pagination.Changed:Connect(function(context)
PreviousButton.Active = context.State.CanGoPrevious
NextButton.Active = context.State.CanGoNext
PageLabel.Text = `{context.State.CurrentPage} / {context.State.PageCount}`
end)
An empty processed dataset still has a conceptual page count of 1, while its page range and item count are 0.
Automatic Page Sizing
Instead of a fixed ItemsPerPage, the handler can measure a UIGridLayout or UIListLayout and calculate how many items fit inside its container.
local pagination = PaginationHandler.new({
AutomaticPageSize = {
Container = ItemsContainer, -- Holds the UIGridLayout/UIListLayout
ItemTemplate = ItemTemplate, -- Required when sizing against a UIListLayout
MinimumItems = 1,
},
GetItemKey = function(item)
return item.Id
end,
})
Container must hold a UIGridLayout or UIListLayout, or one can be supplied explicitly through Layout. ItemTemplate is required when sizing against a UIListLayout, since list items don't expose a cell size the way UIGridLayout does.
The handler listens for changes to the container's size, its UIPadding, the layout's properties, and the item template's size, automatically recalculating ItemsPerPage whenever any of them change. Trigger a manual recalculation with:
local itemsPerPage = pagination:RefreshAutomaticPageSize()
This updates ItemsPerPage if the resolved value changed, and always returns the resolved count.
Searching
The handler normalizes a query before passing it to your search predicate. The default normalizer converts the query to lowercase.
local pagination = PaginationHandler.new({
SearchPredicate = function(item, normalizedQuery)
local name = string.lower(item.Name)
local category = string.lower(item.Category)
return string.find(name, normalizedQuery, 1, true) ~= nil
or string.find(category, normalizedQuery, 1, true) ~= nil
end,
})
pagination:SetSearchQuery("sword")
pagination:SetSearchQuery("") -- Clears the search
A non-empty query requires a search predicate. Swap it at runtime with SetSearchPredicate; the handler reprocesses automatically if a search is currently active:
pagination:SetSearchPredicate(function(item, query)
return string.find(string.lower(item.Name), query, 1, true) ~= nil
end)
You can provide a custom normalizer when constructing the handler:
NormalizeSearchQuery = function(query)
return string.lower(string.gsub(query, "^%s*(.-)%s*$", "%1"))
end
Filtering
Filters are stored by an ID of your choice. Calling SetFilter with the same ID replaces the previous filter.
Built-in Filter
pagination:SetFilter("MinimumRarity", {
KeyPath = "Rarity",
Operator = "GreaterThanOrEqual",
Value = 2,
})
Nested table fields use dot-separated key paths:
pagination:SetFilter("Tradable", {
KeyPath = "Metadata.Tradable",
Operator = "Equals",
Value = true,
})
Supported operators are Equals, NotEquals, GreaterThan, GreaterThanOrEqual, LessThan, LessThanOrEqual, Contains, and In. Add Negate = true to invert any filter.
Selector Filter
Use a selector for computed values or values that cannot be reached through a table key path:
pagination:SetFilter("TotalValue", {
Selector = function(item)
return item.Price * item.Quantity
end,
Operator = "GreaterThan",
Value = 1_000,
})
Predicate Filter
Use a predicate for arbitrary logic:
pagination:SetFilter("CanEquip", {
Predicate = function(item)
return item.LevelRequirement <= playerLevel
and item.Class == playerClass
end,
})
Filter Combination
All requires every active filter to pass. Any requires at least one active filter to pass.
pagination:SetFilterMode("Any")
Remove or clear filters with:
pagination:RemoveFilter("MinimumRarity")
pagination:ClearFilters()
Sorting
Sort rules are evaluated in order. Later rules break ties left by earlier rules.
pagination:SetSortRules({
{
KeyPath = "Rarity",
Direction = "Descending",
},
{
KeyPath = "Name",
Direction = "Ascending",
},
})
Use a selector for computed values:
pagination:AddSortRule({
Selector = function(item)
return item.Price * item.Quantity
end,
Direction = "Descending",
})
Or supply a comparator that returns a negative number, zero, or a positive number:
pagination:SetSortComparator(function(a, b)
return a.DisplayOrder - b.DisplayOrder
end)
When every configured rule considers two items equal, their source order is used as a deterministic fallback.
Page Behavior After Processing
Loading initial data, searches, filters, sorting, data changes, page-size changes, and refreshes can each control where the user remains afterward.
Available behaviors:
FirstPage— move to page 1PreservePage— retain the page number, clamped to the new page countPreserveFirstItem— keep the item that was first on the old page visible by moving to its new page; falls back to preserving the page when that item no longer exists
Configure defaults during construction:
local pagination = PaginationHandler.new({
PageBehaviors = {
InitialData = "FirstPage",
Data = "FirstPage",
Search = "FirstPage",
Filter = "FirstPage",
FilterMode = "FirstPage",
Sort = "PreservePage",
PageSize = "PreserveFirstItem",
Refresh = "PreserveFirstItem",
},
})
Override a behavior for one mutation:
pagination:SetSearchQuery("sword", {
PageBehavior = "PreservePage",
})
Changing the Page Size
pagination:SetItemsPerPage(24)
The default page-size behavior is PreserveFirstItem, so the item at the beginning of the old page remains visible when possible.
Batching Changes
Without batching, every mutation processes the data and refreshes the view immediately. Use Batch when applying several related changes:
pagination:Batch(function()
pagination:SetSearchQuery("sword")
pagination:SetFilter("Owned", {
KeyPath = "Owned",
Operator = "Equals",
Value = true,
})
pagination:SetSortRules({
{ KeyPath = "Rarity", Direction = "Descending" },
{ KeyPath = "Name", Direction = "Ascending" },
})
end)
This performs one final processing pass and emits one combined change context.
You can override the final page behavior:
pagination:Batch(function()
-- Mutations
end, {
PageBehavior = "PreserveFirstItem",
})
Manual batching is also available:
pagination:BeginBatch()
pagination:SetSearchQuery("potion")
pagination:SetFilterMode("All")
pagination:EndBatch()
Nested batches are supported. Only the outermost EndBatch applies pending work.
Stable Item Identity
Stable keys allow the handler to preserve item identity through filtering, sorting, page changes, and renderer reconciliation.
GetItemKey = function(item, sourceIndex)
return item.Id
end
Keys must be unique and non-nil within the supplied dataset.
When no selector is provided:
- Tables and Instances use their reference as the key.
- Primitive values use their source index.
An explicit selector is strongly recommended when items have a persistent ID, especially when SetData may supply newly created tables or reorder primitive values. Change the selector at runtime with SetItemKeySelector; the handler rebuilds source records and reprocesses the dataset:
pagination:SetItemKeySelector(function(item, sourceIndex)
return item.Id
end)
Useful key-based lookups include:
local item = pagination:GetItemByKey(itemId)
local processedIndex = pagination:GetProcessedIndexByKey(itemId)
local page = pagination:GetPageForKey(itemId)
GetProcessedIndexByKey and GetPageForKey return nil when the item is currently filtered or searched out.
Optional Imperative Renderer
A renderer lets the handler reconcile view items by stable key. Rendering remains completely optional.
local pagination = PaginationHandler.new({
ItemsPerPage = 20,
GetItemKey = function(item)
return item.Id
end,
Renderer = {
Create = function(entry, context)
local frame = ItemTemplate:Clone()
frame.Name = tostring(entry.Key)
frame.LayoutOrder = entry.PageIndex
frame.Parent = Container
UpdateItemFrame(frame, entry.Item)
return frame
end,
Update = function(frame, entry, context)
frame.LayoutOrder = entry.PageIndex
UpdateItemFrame(frame, entry.Item)
end,
Destroy = function(frame, entry, context)
frame:Destroy()
end,
Commit = function(entries, context)
EmptyLabel.Visible = #entries == 0
end,
},
})
On each view refresh, the handler:
- destroys rendered keys that left the view;
- updates keys that remain;
- creates keys entering the view;
- calls
Commitwith the final ordered entries; - fires
Rendered.
Renderer errors are caught and reported with warn, allowing pagination state to continue updating.
Provide Destroy whenever Create allocates Instances, connections, observers, or other resources. Swap the active renderer at runtime with SetRenderer — the previous renderer's retained handles are destroyed first.
React, Fusion, and Other Declarative Frameworks
You do not need to use Create, Update, or Destroy. Subscribe to ViewChanged and push the entries into your framework's state:
pagination.ViewChanged:Connect(function(entries, context)
itemsState:set(entries)
end)
A commit-only renderer is another option:
Renderer = {
Commit = function(entries, context)
itemsState:set(entries)
end,
}
This keeps PaginationHandler responsible for data state while the UI framework remains responsible for component lifecycle and reconciliation.
Virtualization
Virtualization limits the exposed view to an index window within the active page. It does not inspect GUI objects, calculate canvas sizes, or cross page boundaries.
Enable and update the window directly:
pagination:SetVirtualizationEnabled(true)
pagination:SetVirtualWindow(
25, -- First visible item index within the active page
12, -- Number of visible items
4 -- Overscan items before and after the visible range
)
GetCurrentPageItems() still returns the full page. GetViewItems() and rendering callbacks return only the virtualized range.
For uniformly sized lists or grids, calculate the window from scroll metrics:
pagination:SetVirtualWindowFromMetrics(
ScrollingFrame.CanvasPosition.Y,
ScrollingFrame.AbsoluteWindowSize.Y,
80, -- Item or row height
8, -- Spacing between rows
4, -- Items per row
8 -- Overscan items
)
Call this when the scroll position or viewport size changes.
By default, the virtual window resets to the start whenever the active page changes. Disable this with Virtualization = { ResetOnPageChange = false }.
The state exposes ItemsBeforeView and ItemsAfterView, which can be used by your UI layer to create spacers or calculate placement. The module does not create those elements automatically.
State and Signals
Read a complete snapshot with:
local state = pagination:GetState()
Available fields:
state.CurrentPage
state.PageCount
state.ItemsPerPage
state.TotalItemCount
state.ProcessedItemCount
state.CurrentPageItemCount
state.PageStartIndex
state.PageEndIndex
state.CanGoPrevious
state.CanGoNext
state.SearchQuery
state.FilterMode
state.ActiveFilterCount
state.SortRuleCount
state.VirtualizationEnabled
state.VirtualStartIndex
state.VirtualVisibleCount
state.VirtualOverscan
state.ViewStartInPage
state.ViewEndInPage
state.ViewStartInData
state.ViewEndInData
state.ViewItemCount
state.ItemsBeforeView
state.ItemsAfterView
state.DataVersion
state.ViewVersion
state.Destroyed
Available signals:
| Signal | Arguments | Purpose |
|---|---|---|
Changed | context | Final general notification after a completed change |
PageChanged | newPage, previousPage, context | Active page changed |
ProcessedChanged | context | Search/filter/sort processing completed |
ViewChanged | entries, context | Active page or virtual view entries changed |
Rendered | entries, context | Optional renderer callbacks completed |
Each context includes a primary reason, all contributing reasons, and a state snapshot:
{
Reason = "Filter",
Reasons = { "Filter" },
State = pagination:GetState(),
}
Batched changes use Batch as the primary reason and list the individual reasons in Reasons.
Data Ownership and Performance
- Input arrays are read with
ipairs; use dense arrays without holes. - The handler copies array membership into internal records.
- Item values themselves are not deep-cloned. Tables and Instances remain shared references.
- Getter methods return new arrays and copied entry/spec tables, preventing accidental mutation of internal collections.
- Processing is
O(n)for filtering/searching plusO(n log n)when sorting is active. - Batching avoids repeated processing and rendering when changing several query settings together.
- Virtualization reduces the number of entries sent to a renderer, but processing still evaluates the complete source dataset.
Cleanup
pagination:Destroy()
Destroy calls the active renderer's Destroy callback for retained handles, disconnects any AutomaticPageSize observers, clears internal data, and destroys all BindableEvents.
Mutating a destroyed handler raises an error. Disconnect any external signal connections as part of the surrounding controller's normal cleanup.
⚙️ API Reference
PaginationHandler.new
PaginationHandler.new(config: Config?)
Creates a pagination handler. PaginationHandler.New is an identical PascalCase alias.
Data
pagination:SetData(items, options)
pagination:Refresh(options)
pagination:SetItemKeySelector(selector, options)
Search
pagination:SetSearchQuery(query, options)
pagination:SetSearchPredicate(predicate, options)
pagination:GetSearchQuery()
Filtering Methods
pagination:SetFilter(filterId, filter, options)
pagination:RemoveFilter(filterId, options)
pagination:ClearFilters(options)
pagination:SetFilterMode(mode, options)
pagination:GetActiveFilters()
Sorting Methods
pagination:SetSortRules(rules, options)
pagination:AddSortRule(rule, index, options)
pagination:RemoveSortRule(index, options)
pagination:ClearSortRules(options)
pagination:SetSortComparator(comparator, direction, options)
pagination:GetSortRules()
Pagination
pagination:SetPage(pageNumber)
pagination:NextPage()
pagination:PreviousPage()
pagination:FirstPage()
pagination:LastPage()
pagination:SetItemsPerPage(itemsPerPage, options)
pagination:GetCurrentPage()
pagination:GetPageCount()
pagination:GetItemsPerPage()
pagination:CanGoNext()
pagination:CanGoPrevious()
Automatic Page Sizing Methods
pagination:RefreshAutomaticPageSize()
Requires AutomaticPageSize to have been supplied at construction.
Virtualization Methods
pagination:SetVirtualizationEnabled(enabled)
pagination:SetVirtualWindow(startIndex, visibleCount, overscan)
pagination:SetVirtualWindowFromMetrics(scrollOffset, viewportExtent, itemExtent, spacing, itemsPerLine, overscan)
Batching
pagination:BeginBatch(options)
pagination:EndBatch()
pagination:Batch(callback, options)
Reading State and Data
pagination:GetState()
pagination:GetSourceItems()
pagination:GetProcessedItems()
pagination:GetCurrentPageEntries()
pagination:GetCurrentPageItems()
pagination:GetViewEntries()
pagination:GetViewItems()
pagination:GetItemByKey(key)
pagination:GetProcessedIndexByKey(key)
pagination:GetPageForKey(key)
pagination:GetTotalItemCount()
pagination:GetProcessedItemCount()
Renderer
pagination:SetRenderer(renderer)
Destroy
pagination:Destroy()
Calls the active renderer's Destroy callback for retained handles, disconnects AutomaticPageSize observers, clears internal data, and destroys all BindableEvents. Always call Destroy() when a handler is permanently no longer needed.
Configuration Reference
A representative configuration looks like:
local pagination = PaginationHandler.new({
ItemsPerPage = 20,
AutomaticPageSize = nil, -- { Container, ItemTemplate, Layout, MinimumItems }
InitialData = nil,
GetItemKey = nil,
SearchPredicate = nil,
NormalizeSearchQuery = nil, -- defaults to string.lower
FilterMode = "All",
SortRules = nil,
PageBehaviors = {
InitialData = "FirstPage",
Data = "FirstPage",
Search = "FirstPage",
Filter = "FirstPage",
FilterMode = "FirstPage",
Sort = "PreservePage",
PageSize = "PreserveFirstItem",
Refresh = "PreserveFirstItem",
},
Virtualization = {
Enabled = false,
StartIndex = 1,
VisibleCount = nil, -- defaults to ItemsPerPage
Overscan = 0,
ResetOnPageChange = true,
},
Renderer = nil, -- { Create, Update, Destroy, Commit }
})
You only need to specify values that differ from the defaults.
Design Goals
PaginationHandler is designed around a few principles.
Data should own pagination state
Search, filter, sort, page, and virtualization state live in the handler rather than in loose UI variables or Instance Attributes.
Rendering should remain optional
ViewChanged and the read-only getters make the handler fully usable without ever touching Renderer. The Create/Update/Destroy/Commit callbacks exist for imperative UI, not as a requirement.
Every handler should be independent
Each handler owns its own data, state, and BindableEvents. There is no singleton pagination state, and multiple handlers can run side by side — inventories, shops, catalogs, leaderboards, quest lists, admin panels, save slots — without interfering with one another.
📝 Notes
- Input arrays are read with
ipairs; use dense arrays without holes. - Keys returned by
GetItemKeymust be unique and non-nilwithin the supplied dataset; a duplicate key throws an error. - A non-empty search query requires a
SearchPredicateto already be configured. GetProcessedIndexByKeyandGetPageForKeyreturnnilwhen the item is currently filtered or searched out.- An empty processed dataset still reports a page count of
1, with a page range and item count of0. - Item values are not deep-cloned; tables and Instances remain shared references. Getter methods return copies of entry/spec tables, not the underlying item.
AutomaticPageSizerecalculatesItemsPerPagefrom aUIGridLayoutorUIListLayout's cell/item size whenever the container, its padding, the layout, or the item template changes size.- Renderer callback errors are caught and reported with
warn, so a failingCreate/Update/Destroy/Commitdoes not stop pagination state from continuing to update. - The module is framework-independent and works with plain Roblox UI, React Luau, Fusion, or no renderer at all.
License
MIT — see LICENSE.
made with ❤️ by biotoxin495
Package Details
Install command (Click to copy)
Version
1.0.0
License
MIT
Safe for commercial use
Automated license review — not legal advice.
