Datatables is a very handy front end package to render large datasets with ease. However, writing code to configuring multiple datatables gets repetitive. For my case, I wrote a JS class to extend Stimulus that let's me declare a new server side dateable by simply providing the URL and declaring the columns like so:

Sample HTML

<div class="card-datatable table-responsive">
  <table class="table table-striped" id="example-datatable"
         data-controller="example-datatable"
         data-example-datatable-url-value="<%= some_datatable_path %>"
         data-example-datatable-item-path-value="<%= some_index_path %>"
  >
    <thead>
    <tr>
      <th>Created At</th>
      <th>Name</th>
      <th>View</th>
    </tr>
    </thead>
  </table>
</div>

You can then attach a stimulus controller as seen below. You still need a controller per dateable, but this is to significantly reduce the amount of boilerplate code written while still maintaining flexibility.

Sample JS

import { StimulusDatatable } from "controllers/datatable_controller";

// Connects to data-controller="example-datatable"
export default class extends StimulusDatatable {
  config() {
    const itemPath = this.itemPathValue;

    return {
      searching: false,
      columns: [
        {
          data: function (row, _type, _set) {
            return `<span data-controller="format--date">${row.created_at}</span>`;
          }
        },
        { data: "name" },
        {
          data: function (row, _type, _set) {
            return `<div class="float-right">
                      <a href="${itemPath}/${row.route_token}" class="btn btn-sm btn-outline-primary">
                        <i class="tf-icons ti ti-chevrons-right"></i>
                      </a>
                    </div>`;
          }
        }
      ],
      columnDefs: [
        { name: "created_at", targets: 0 },
        { name: "name", targets: 1 },
        { name: "view", targets: 2, orderable: false, searchable: false },

        { targets: "_all", searchable: true, orderable: true }
      ],
      exportColumns: [0, 1],
      stimulusDateRange: true
    };
  }
}

Stimulus Class

The actual class is below. I'd suggest reading up on data tables configuration, as I tweaked this to my specific use, such as custom rendering on the export buttons and an option date range picker, plus default search.

import { Controller } from '@hotwired/stimulus';
import jQuery from 'jquery';
import 'datatables';

/*
  Note for GitHub. Check the classes used in the HTML.
  It's configured for the Bootstrap theme that I use, but you'll probably need something different.
  Also take note of the usualConfig() method. It's a good starting point for your own config.
 */

/*
    This is the base class for all datatable controllers.
    mostly pulled from https://github.com/jgorman/stimulus-datatables/blob/master/src/index.js, but heavily modified
    The config that is sent must include the following:
    columns: [
      { data: 'sku' },
      { data: 'name' },
      { data: 'stock' },
      {
        data: function (row, _type, _set) {
          return `<a href="${itemPath}/${row.id}" class="btn btn-outline-primary my-0 mr-0">
                          <i class="tf-icons ti ti-chevrons-right"></i>
                        </a>`;
        }
      }
    ],
    columnDefs: [
      { name: 'sku',   targets: 0 },
      { name: 'name',  targets: 1 },
      { name: 'stock', targets: 2 },
      { name: 'view',  targets: 3, 'orderable': false, 'searchable': false },
      { targets: '_all', searchable: true, orderable: true}
    ],
    exportColumns: [0, 1, 2, 3],
    exportColumns are not part of the DataTable API, but are required for the export to work.
 */
export class StimulusDatatable extends Controller {

  usualConfig() {
    return {
      processing: true,
      serverSide: true,
      paging: true,
      pagingType: 'full_numbers',
      searching: true,
      lengthChange: true,
      responsive: true,
      stateSave: true,
      fixedHeader: true,
      order: [[0, 'asc']],
      drawCallback: function () {
        'use strict';
      },
      dom: '<"card-header flex-column flex-md-row"<"head-label text-center"><"dt-action-buttons text-end pt-3 pt-md-0"B>><"row"<"col-sm-12 col-md-6"l><"col-sm-12 col-md-6 d-flex justify-content-center justify-content-md-end"f>>t<"row"<"col-sm-12 col-md-6"i><"col-sm-12 col-md-6"p>>',
      exportColumns: [0, 1],
      stimulusDateRange: false,
      tableTitle: ''
    };
  }


  static values = {
    url: String,
    itemPath: String
  }

  // this will get overridden by the child controller
  config() {}

  initialize() {
    // console.log("Stimulus Datatable initialized");
  }

  connect() {
    const usualConfig = this.usualConfig();

    /*
    if the element does not have an id, add one
    note that this will not persist between page loads and will fill up local storage if you don't add an ID.
    so it's recommended to add one
    */
    if (!this.element.id) {
      this.element.id = `datatable-${Math.random().toString(36).substring(7)}`;
    }
    const elementID = this.element.id;

    if (!this.isBooting()) {
      return false;
    }

    // Register the teardown listener
    document.addEventListener('turbo:before-render', this._teardown);


    let config = usualConfig;
    // so you can overwrite default values
    Object.assign(config, this.config());

    const that = this;

    config.initComplete = function(_settings, _json) {
      const tableInput = jQuery('div.dataTables_filter input');

      tableInput.unbind();
      tableInput.bind('keyup', function(e) {
        if(e.keyCode === 13) {
          that.dataTable.search(this.value).draw();
        }
      });
    };

    if (this.config().buttons === undefined) {
      config.buttons = [
        {
          extend: 'collection',
          className: 'btn btn-label-primary dropdown-toggle me-2',
          text: '<i class="ti ti-file-export me-sm-1"></i> <span class="d-none d-sm-inline-block">Export</span>',
          buttons: [{
            extend: 'print',
            text: '<i class="ti ti-printer me-1"></i>Print',
            className: 'dropdown-item',
            exportOptions: {
              columns: config.exportColumns,
            }
          }, {
            extend: 'csv',
            text: '<i class="ti ti-file-text me-1"></i>Csv',
            className: 'dropdown-item',
            exportOptions: {
              columns: config.exportColumns,
            }
          }, {
            extend: 'excel',
            text: '<i class="ti ti-file-text me-1"></i>Excel',
            className: 'dropdown-item',
            exportOptions: {
              columns: config.exportColumns,
            }
          }, {
            extend: 'pdf',
            text: '<i class="ti ti-file-description me-1"></i>Pdf',
            className: 'dropdown-item',
            exportOptions: {
              columns: config.exportColumns,
            }
          }, {
            extend: 'copy',
            text: '<i class="ti ti-copy me-1"></i>Copy',
            className: 'dropdown-item',
            exportOptions: {
              columns: config.exportColumns,
            }
          }]
        }
      ];
    }

    if (this.config().ajax === undefined) {
      config.ajax = {
        url: this.urlValue,
        type: 'GET'
      };
    }

    if (this.config().order === undefined) {
      config.order = [[ 0, 'desc' ]];
    }

    if (this.config().searching === undefined) {
      config.searching = usualConfig.searching;
    }

    if (this.config().dom === undefined || this.config().dom === '' || this.config().dom === null) {
      config.dom = usualConfig.dom;
    }

    // pulling it from config tends to persist across pages and tables... so pull it directly from the config that we set
    if (this.config().stimulusDateRange === true) {
      this.stimulusDateRange = true;
    } else {
      this.stimulusDateRange = false;
    }

    const dateRangeSelectID = `${elementID}-table-date-range`;

    if (this.stimulusDateRange) {
      config.ajax = {
        url: this.urlValue,
        type: 'GET',
        data: function (d) {
          d.date_range = jQuery(`#${dateRangeSelectID}`).val();
        }
      };

      // add item to config.buttons
      config.buttons.push({
        text: '',
        className: `d-none ${elementID}-date-range-replace`
      });
    }

    this.dataTable = jQuery(this.element).DataTable(config);

    if (this.stimulusDateRange) {

      // const tableBar = jQuery(`#${elementID}_wrapper > div.row > div.col-sm-12.col-md-6.d-flex.justify-content-center.justify-content-md-end`);
      const replaceElement = jQuery(`.${elementID}-date-range-replace`);

      const dateRangeSelectHTML = `<input type="text" id="${dateRangeSelectID}" class="form-control flatpickr-range flatpickr-input" placeholder="YYYY-MM-DD to YYYY-MM-DD" data-controller="format--date-range" readonly style="width: min-content;">`;

      // the element above this element has the btn-group in the div, and it renders incorrectly. So this makes it render as if it's an individual button
      replaceElement.parent().children().eq(0).removeClass('btn-group');

      // if the element with the ID doesn't exist, append it
      if(!document.getElementById(dateRangeSelectID)) {
        // replace replaceElement with dateRangeSelectHTML
        replaceElement.replaceWith(dateRangeSelectHTML);
      }

      const dateRangeElement = jQuery(`#${dateRangeSelectID}`);
      dateRangeElement.change(function () {
        if (dateRangeElement.val().includes(' to ')) {
          that.dataTable.ajax.reload();
        }
      });
    }

    // if config.tableTitle is set, add it to the table
    if (this.config().tableTitle !== undefined) {
      if (this.config().tableTitle.length > 0) {
        jQuery(`#${elementID}_wrapper div.head-label`).html(`<h5 class="card-title mb-0">${this.config().tableTitle}</h5>`);
      }
    }
  }

  isTable() {
    return this.element.tagName === 'TABLE';
  }

  isDataTable() {
    return this.element.className.includes('dataTable');
  }

  isPreview() {
    return document.documentElement.hasAttribute('data-turbo-preview');
  }

  isLive() {
    return this.dataTable;
  }

  isBooting() {
    return this.isTable() && !this.isDataTable() && !this.isPreview() && !this.isLive();
  }

  _teardown = () => this.teardown(this);

  teardown(_event) {
    if (!this.isLive()){
      return false;
    }

    document.removeEventListener('turbo:before-render', this._teardown);
    this.dataTable.destroy();
    this.dataTable = undefined;

    return true;
  }
}

// The below code probably isn't needed but I've left it in because I'm lazy.

// Connects to data-controller="datatable"
export default class Datatable extends StimulusDatatable {
  static get shouldLoad() {
    return false;
  }

  connect() {
    // console.log("Datatable connected");
  }
}

This code can also be found on GitHub Gists here.