Hoarding Order With Livewire

Photographs of buildings stacked up one after the other, forming a spiraling line of photographs fading away into the background.
Image by Annie Ruygt

This post explores the use of Livewire’s polling and event mechanisms to accumulate and maintain ordering of groupable data across table pages. If you want to keep your polling up to speed, deploy your Laravel application close to your users, across regions, with Fly.io—you can be up and running in a jiffy!

In this post we’ll craft ourselves a hassle-free ordering of groupable data across table pages, and land our users a lag-free pagination experience.

In order to do so, we’ll use Livewire’s polling feature to accumulate ordered data, and keep a client-side-paginated table up to date with polling results through Livewire’s event mechanism.

Does that sound exciting, horrifying, or both?

Any of the above’s a great reason to read on below!

Following Along

You can check out our full repository here, clone it, and make some pull requests while you’re at it! Afterwards, connect to your preferred database, and execute a quick php artisan migrate:fresh --seed to get set up with data.

If you’re opting out of the repository route, you’ll need a Laravel project that has both Livewire and Tailwind configured. Make sure you have a groupable dataset with you so you can see the magic of our approach in maintaining grouped data order across pages. Run php artisan make:livewire article-table to create your Livewire component, and you’re free to dive in below.

Version Notice. This article focuses on Livewire v2, details for Livewire v3 would be different though!

Paginating carefully arranged data

What’s a good approach in displaying a meticulously arranged list of data?

Let’s say we receive and record “bookmarks"—article links—from news feeds we follow. These bookmarks can be grouped together based on some logic manually or automatically imposed. As an example, we may encounter exactly the same bookmark from two or more different feeds. If we’re allowing duplicate bookmarks from different feeds, then it makes sense to group these similar bookmarks together, like so:

An illustration of a table containing three column headers: Url, Feed Name, and Date. It contains six rows of article links received from different news feed sources that got recorded from the dates September 10, 11, 12, and 13. The third, fourth, and fifth rows have the same article links, but were retrieved from different sources. These rows are grouped together due their reference to the exact same article link. The illustration refers to the third row which came in on September 11 as the Lead of the group, and refers to the fourth and fifth rows that both came in on September 13 as its Sub. Other rows not belonging to a group are referred to as Normal.
A table containing records of bookmarks received from various feed sources. These bookmarks are groupable together, containing a LEAD row and its Sub rows.

Grouping table rows that don’t sequentially come together is tricky. As seen from the image above, rows in a group don’t necessarily come in the same day, nor do they get saved one after the other. Amidst this absence of natural ordering in our database, we’ll have to carefully arrange our data every time we want to display them.

Furthermore, we’ll need to paginate our data to avoid bottlenecks from database retrieval, processing, and client data download when working with humongous data sets.

A screen recording of a browser window showing a page containing a table, a mouse pointer highlights the ID, LEAD ID, and URL of a group of related rows spanning from the current page to the next. The table contains four column headers: Url, Source, ID, and Lead ID. There are ten rows shown in the current page. The nineth and tenth row have the same url, because of this they are grouped together. The screen recording starts with a mouse pointer highlighting the nineth row. Next it highlights the nineth row's ID which has a value 21. The mouse pointer goes to the tenth row and highlights the row's LEAD ID which is also 21, indicating that the tenth row is a Sub row of the nineth row. The mouse pointer clicks on the Next button of the table. This interaction renders the next set of ten rows. The first row contains the Lead ID 21, indicating that this is also a Sub of the previous Lead row. And it is! The mouse pointer highlights its Lead ID, 21, then highlights its URL. The mouse pointer navigates to and clicks the Prev button. This interaction renders the rows of the previous page. The mouse pointer highlights the tenth row's URL, then the nineth row. They are exactly the same URL as the first row found in the Next page.
A group of bookmark rows spans from the current page( nineth and tenth rows ) to the next page( first row ).

Pagination while maintaining order of data in different pages of a table can become complicated only too quickly. We’ll have to make sure that what we’ve shown in previous pages are still there when users return, and especially maintain ordering of groups that encompasses more than one page.

Client Side Pagination

—with a side dish of data accumulation.

A screen recording of a browser window showing a page visibly open to the left and a network calls view visibly open to its right, a mouse pointer moves between the two sections of the browser window, guiding viewers on the silent accumulation of data through continuous network calls. Inside the page is a table with four column headers: Url, Source, ID, and Lead ID. Above the table comments are indicated. From left to right these comments are: "Current Rows:51", "Max Rows:457", and "Loading more data...". The moving image starts with the mouse pointer highlighting the comment "Loading more data...". The pointer next highlights the comment "Current Rows:51", indicating to the viewers "we initially had 51 rows in our page". Next, A new network call identified by the name "article-table" suddenly gets listed in the network calls view. This listing means that the page made a request to the server to get more data. The mouse pointer circles around this name four times before traveling back to the "Current Rows:51" comment. The network call identified as article-table completes after 2.461 seconds. This completion updates the comment "Current Rows:51" to "Current Rows:289". The mouse pointer highlights this change in the comment, and travels back to the network calls view. The mouse pointer circles around the second "article-table" network call that popped up. This network call completes in just milliseconds. After it completes, the mouse pointer again highlights the change in the comment "Current Rows:289" to "Current Rows:457". The last comment above the table "Loading more data..." disappears.
This table initially held 51 bookmark rows. As background processes poll for more data, the number of rows it contains eventually reach the total record of 457 rows.

With Livewire, we can easily set up a table component with public attributes keeping track of the list of items, accumulating data over time with their ordering intact.

Accumulating data means we’ll have an evolving list of properly ordered data. Therefore, we can simply rely on displaying indices specific to a page and not worry about grouping logic during pagination.

Another added benefit of our approach is client-side pagination. Livewire smoothly integrates JavaScript listeners with server-side events, allowing our rendered page’s JavaScript to easily update our client’s accumulated data.

As a result, our users’ interaction can rely solely on the data accumulated, without any calls to our server for data—all thanks to the silent accumulation provided by Livewire:poll.

An illustration of the flow of events between the server Livewire component and the client Livewire component, with an inclusion of functionalities used for user interaction.
This diagram separates the flow of initialization, data accumulation, and user interaction between the Livewire component’s controller and view

Below, we’ll set up our Livewire controller’s public attributes and functionalities to retrieve and accumulate ordered data. Then on the last step, we’ll update our Livewire view to display and refresh our accumulated data with events and polling.

Controller

Let’s make some changes to our /app/http/Livewire/ArticleTable.php:

# /App/Http/Livewire/ArticleTable.php

class ArticleTable extends Component
{

    // List that accumulates data over time
    public $dataRows;

    // This is total number of data rows for reference, 
    // Also used as a reference to stop polling once reached
    public $totalRows;

    // Used for querying next batch of data to retrieve
    public $pagination;
    public $lastNsId;

    // Override this to initialize our table 
    public function mount()
    {
        $this->pagination = 10;
        $this->initializeData();
    }

Polling Accumulation

For smooth sailing for our users, we’ll set up a client-side paginated table that allows table interaction to be lag-free.

We’ll initially query the necessary rows to fit the first page in the client’s table—with a pinch of allowance. This should be a fast enough query that takes less than a second to retrieve from the database, and the size of the data returned shouldn’t be big enough to cause a bottleneck in the page loading.

What makes this initial data retrieval especial is the extra number of rows it provides from what is initially displayed. We get double the first page display, so there is an allowance of available data for next page intearction.

/**
 * Initially let's get double the first page data 
 * to have a smooth next page feel 
 * while we wait for the first poll result
 */
public function initializeData()
{
    $noneSubList = $this->getBaseQuery()
    ->limit($this->pagination*2)
    ->get();

    $this->addListToData( $noneSubList );
}

/**
 * Gets the base query
 */
public function getBaseQuery()
{
    // Quickly refresh the $totalRows every time we check with the db
    $this->totalRows = Article::count();

    // Return none-Sub rows to avoid duplicates in our $dataRows list
    return Article::whereNull('lead_article_id');
}


Then in order to get more data into the table, Livewire from the frontend can quietly keep adding items to the table through polling one of the controller’s public functions: nextPageData.

/**
 * For every next page, 
 * we'll get data after our last reference
 */
public function nextPageData()
{
  $noneSubList = $this->getBaseQuery()
    ->where('id','>',$this->lastNsId)
    ->limit($this->pagination*10)
    ->get();

  $this->addListToData( $noneSubList );
}

Add in our core functionality for ordering our data retrieved: get possible sub rows for the data result, merge data inclusive of their sub rows in proper ordering to our $dataRows.

 /**
 * 1. Get possible Sub rows for the list of data retrieved in nextPageData or initializeData
 * 2. Merge list of data inclusive of their possible Sub rows, in proper ordering, to the accumulated $dataRows 
 * 3. Update the $lastNsId reference for our nextPage functionality 
 */
public function addListToData($noneSubList)
{
    $subList = $this->getSubRows($noneSubList);
    foreach( $noneSubList as $item ){
        $this->dataRows[] = $item;
        $this->lastNsId   = $item->id;
        foreach( $subList as $subItem){
            if( $subItem->lead_article_id == $item->id ){
                $this->dataRows[] = $subItem;
            }
        }
    }
}


/**
 * Get the Sub rows for the given none-Sub list
 */
private function getSubRows($noneSubList)
{
    $idList = [];
    foreach($noneSubList as $item){
        $idList[] = $item->id;
    }

    return Article::whereIn('lead_article_id', $idList)->get();
}

View

Once we have our Livewire controller set up, let’s bring in some color to our Livewire-component view, /app/resources/views/livewire/article-table.blade.php:

<table>
  <thead>...</thead> 
  {{-- wire:ignore helps to not reload this tbody for every update done on our $dataRows --}}
  <tbody id="tbody" wire:ignore></tbody>
</table>
<nav role="navigation" aria-label="Pagination Navigation" class="flex justify-between" >
    <button onclick="prevPage()">Prev</button>
    <button onclick="nextPage()">Next</button>
</nav>

What makes our setup pretty cool is that pagination will be done strictly client-side. This means user interaction is available only on data the UI has access to—meaning from the JavaScript side of things—and consequently, a lag-free pagination experience for our users!

Client-Side Pagination

To display our data, we initialize all variables we need to keep track of from the JavaScript side. This includes a reference to our table element, some default pagination details, and finally a myData variable to easily access the data we received from $dataRows.

Go ahead and add in a <script> section to our Livewire-component view in /app/resources/views/livewire/article-table.blade.php:

<script>
  // Reference to table element
  var mTable   = document.getElementById("myTable");
  // Transfer $dataRows to a JavaScript variable for easy use
  var myData   = JSON.parse('<?php echo json_encode($dataRows) ?>');
  // Default page for our users
  var page     = 1;
  var startRow = 0;
  // Let's update our table element with data
  refreshPage();

Then set up a quick JavaScript function that will display myData rows in our tbody based on the current page.

function refreshPage()
{
    // Let's clear some air 
    document.getElementById("tbody").innerHTML = '';

    // Determine which index/row to start the page with
    startRow = calculatePageStartRow(page);

    // Add rows to the tbody
    for(let row=startRow; row<myData.length && row<startRow+10; row++){
        let item = myData[row];
        var rowTable = mTable.getElementsByTagName('tbody')[0].insertRow(-1);

        // Coloring scheme to differentiate Sub rows
        if(item['lead_article_id']!=null){
            rowTable.className = "pl-10 bg-gray-200";
            var className = "pl-10";   
        }else
            var className = ""; 

        var cell1 = rowTable.insertCell(0);
        var cell2 = rowTable.insertCell(1);
        var cell3 = rowTable.insertCell(2);
        var cell4 = rowTable.insertCell(3);

        cell1.innerHTML = '<div class="py-3 '+className+' px-6 flex items-center">' + item['url'] + '</div>';
        cell2.innerHTML = '<div class="py-3 '+className+' px-6 flex items-center">' + item['source'] + '</div>';
        cell3.innerHTML = '<div class="py-3 '+className+' px-6 flex items-center">' + item['id'] + '</div>';
        cell4.innerHTML = '<div class="py-3 '+className+' px-6 flex items-center">' + item['lead_article_id'] + '</div>';
    }
}

Along with functionality to allow movement from one page to the next and back:

function nextPage()
{   
    if( calculatePageStartRow( page+1 ) < myData.length ){
        page = page+1;
        refreshPage();
    }
}

function prevPage()
{
    if( page > 1 ){
        page = page-1;
        refreshPage();
    }
}

function calculatePageStartRow( mPage )
{
    return (mPage*10)-10;
}

Discreetly add in our accumulation of remaining data with the help of Livewire’s magical polling feature that can eventually stop once we’ve reached maximum rows:

@if( count($dataRows) < $totalRows )
    <div wire:poll.5s>
        Loading more data... 
        {{ $this->nextPageData() }}
        {{ $this->dispatchBrowserEvent('data-updated', ['newData' => $dataRows]); }}
    </div>
@endif

And finally, in response to the dispatchBrowserEvent above, let’s create a JavaScript listener to refresh our myData list and re-render our table rows—just in case the current page still has available slots for rows to show.

window.addEventListener('data-updated', event => {
   myData = event.detail.newData;
   refreshPage();
});

And that’s it! We’re done, in less than 300 lines of logic!

We now have our easy-to-maintain, ordered listing of groupable data, paginated in a table. We boast a client-side-only pagination that promotes a lag-free experience for our users’ navigation interaction.

Fly.io ❤️ Laravel

Fly.io is a great way to run your Laravel Livewire app close to your users. Deploy globally on Fly in minutes!

Deploy your Laravel app!

Retrospect

If there’s one treasure that should stay with us from our journey today, it’s this:

Learning comes from retrospect.

—Don’t be afraid to look up the past: it’s where we’ve all been through, it’s how we learn from our actions, and it’s what drives us forward to new, exciting roads—heading towards our next destination.

Take a deep breath. Let’s recap what we’ve been through today:

Problem:

Grouping rows and displaying them across pages in a table can get complicated thanks to the obligation of maintaining order of groupings across pages.

Solution:

Livewire provides its polling mechanism to call server functions periodically. It also gives out this marvelous integration of JavaScript listeners with PHP Livewire events.

To solve the complexity of grouping rows in a paginated table, we learned that we can initially have a short list of ordered data—hopefully with some allowance. Afterwards we just needed to keep the table’s data bustling and updated with Livewire's polling mechanism to keep user-interaction strictly in the client side of things.

For the last puzzle piece of our setup, we used Livewire's JavaScript event listener to update data in our client page’s table after every completed poll done from the background.

With data accumulation happening in the background, we were able to implement our user-table interaction purely on client-side JavaScript, eliminating any server-induced next-page data-request lag.

The approach we’ve implemented is not perfect by any means. It has benefits and drawbacks:

Benefits:

  1. Data accumulation reduces complexity while maintaining the order of data with pagination.
  2. User interaction is sublimely performant because it only deals with local data.

Drawbacks:

  1. Because polling happens automagically in the background until we complete our entire dataset, we risk unnecessary queries to the server for the duration our user has the page actively opened.
  2. Livewire sends back all public attributes with every call. The closer our dataset gets to completion, the larger the data returned by the server.

Experiment!

Maybe the polling bit until all data was consumed was a bit overwhelming—might have given some of us a spike to the good-old blood pressure, it really depends on the circumstances of our application, and our personal health. But, see—and I hope you see this—with Livewire, there’s just tons of ways that our user-table interaction can evolve!

  1. Want to remove the polling part but keep the client-side pagination? Keep the data accumulation, but replace polling with extra allowance from our next page—show the next 10 rows, but append 20 rows to our accumulated list!
  2. Want to add search and sorting? You can opt with working on the current accumulated data as usual, if that’s a bit too naivet, how about a refresh of the list?

The fun part in the two experiments you can try out above is you can still keep the pagination client-side.

Let’s wrap up our journey here

I hope you learned a thing or two from this blog post. If you have any questions, concerns, or constructively kind criticism, be sure to let me know!

I’m always up for conversation and just a tweet post away.