vakata/jstree
jsTree is a jQuery plugin for creating interactive, accessible tree views. It supports HTML and JSON data sources, drag-and-drop, searching, checkboxes, lazy loading, themes, and a plugin architecture, with broad browser support and customizable behavior.
jsTree is a client-side jQuery plugin — there is no service provider, config file, or Composer package to install. In a Laravel app the "integration" is entirely: (a) get the JS/CSS into Vite, (b) write JSON endpoints, (c) wire events back to a controller. Budget your time accordingly; 80% of the work is the JSON contract.
Install:
npm i jquery jstree
npm i -D @types/jstree # no bundled types
Fix the jQuery global before anything else. jsTree's dist bundle expects window.jQuery. Because ES imports are hoisted, import $ from 'jquery'; window.$ = $; import 'jstree' does not work reliably. Use a shim module so evaluation order is explicit:
// resources/js/jquery.js
import $ from 'jquery';
window.$ = window.jQuery = $;
export default $;
// resources/js/app.js
import $ from './jquery'; // evaluated first → window.jQuery set
import 'jstree'; // now sees the global
import 'jstree/dist/themes/default/style.min.css';
Where to look first: the core.data docs section (node JSON shape), the plugin list, and dist/ in node_modules/jstree (jstree.js, themes/default/style.min.css, sprite 32px.png/throbber.gif). Skim demo/ in the repo for a reference backend, but ignore jstree-php — it is legacy PHP, not Laravel.
First use case — a category tree backed by one query:
// routes/web.php
Route::get('/categories/tree', [CategoryTreeController::class, 'index']);
public function index()
{
return Category::orderBy('position')->get(['id', 'parent_id', 'name'])
->map(fn ($c) => [
'id' => 'cat_'.$c->id, // string ids, see Gotchas
'parent' => $c->parent_id ? 'cat_'.$c->parent_id : '#',
'text' => $c->name,
'data' => ['slug' => $c->slug], // custom payload
]);
}
<div wire:ignore x-data x-init="
const $t = $(this.$refs.tree);
$t.jstree({
core: { data: @js($nodes), multiple: false, check_callback: true },
plugins: ['wholerow', 'state', 'types'],
types: { '#': { valid_children: ['default'] } }
}).on('changed.jstree', (e, d) => {
if (d.selected.length) Livewire.dispatch('category-picked', { id: d.selected[0] });
});
" x-ref="tree"></div>
That's a working read-only tree. Everything below is the escalation path.
Pick one shape and stick to it. jsTree accepts nested children or flat parent references — never both; if children exists, parent is ignored for that node. Nested is simpler for small trees; flat is what you want as soon as you add AJAX.
Lazy loading per node. Serve only roots first, then children on expand. The reliable form is a fixed URL plus a data callback so the request shape is explicit rather than relying on jsTree's URL-id appending:
core: {
data: (node) => ({
url: '/categories/tree',
data: (n) => ({ parent: n.id === '#' ? null : n.id.replace('cat_', '') }),
dataType: 'json',
}),
cache: false,
}
Or a dedicated route with model binding: Route::get('/categories/{category}/children', ...). Guard authorization per-branch ($this->authorize('view', $category)) — lazy endpoints are as exposed as any other route.
Drag-and-drop persistence. Enable dnd + types, constrain with valid_children/max_depth, and gate with core.check_callback. Then persist from the move_node event:
.on('move_node.jstree', (e, d) => {
if (d.instance !== tree) return; // ignore other trees
axios.patch(`/categories/${id(d.node.id)}/move`, {
parent_id: parentId(d.parent),
position: d.position,
}).catch(() => d.instance.refresh_node(d.parent));
})
Server-side, wrap in DB::transaction, renumber siblings, and reject cycles (check_callback runs client-side only — a crafted request can still make a node its own ancestor). Walk ancestors before saving.
Checkboxes for bulk actions. plugins: ['checkbox'], checkbox.tie_selection: false, then tree.get_checked() for cascade semantics, or get_bottom_checked() / get_top_checked() when you only want leaves / roots. Sync to a pivot with sync().
Search. Client-side search only sees loaded nodes — fine for a fully-inlined tree, wrong for a lazy one. For lazy trees add a server endpoint and use search.ajax, returning the node plus its ancestor chain so jsTree can open the path:
plugins: ['search'],
search: { show_only_matches: true, ajax: { url: '/categories/search', data: t => ({ q: t }) } }
Reusable Blade/Alpine component. Wrap init in x-init/onMounted, call $t.jstree('destroy') on teardown, and put wire:ignore on the container so Livewire doesn't re-render the DOM out from under jsTree. Expose the instance ($t.jstree(true)) rather than re-selecting.
Server-driven selection & state. state: { key: 'categories-'.$user->id } persists open/selected nodes to localStorage; for cross-device persistence, store the expanded ids in a preferences column and open_node() them after ready.jstree fires. Use open_node(id, cb, true) to avoid animation storms on deep paths.
Types plugin for icon/rules per model. types: { folder: { icon: 'i-folder', max_children: 500 }, file: { icon: 'i-file', max_depth: 1 } } keeps policy in config instead of scattered check_callback branches, and lets you map Eloquent is_folder/mime straight to a type name.
Testing. Feature-test the JSON endpoints (shape, authorization, cycle rejection) and use Dusk for the interaction itself — dragging in jsTree via unit tests is not worth it.
$('#id') internally, so ids containing ., :, [, or starting with a digit break get_node, select_node, and open_node. Prefix everything: cat_123, user_9. This is the single most common bug report.data.original keeps your types. fromJson(["id" => 3]) yields "3" on the client; node.id === 3 is false. Always String() compare. Conversely, anything you attach in JSON (including the data key) is preserved on node.original — use it instead of a second fetch on select.move_node fires for programmatic moves too. If you build nodes server-side and insert them, guard the sync handler with a flag or you'll PATCH in a loop.POST/PATCH data endpoints add headers: { 'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content } to core.data, and remember SPA/Inertia apps need X-Requested-With to get JSON 422s instead of redirects..jstree({...}) on an initialized container silently no-ops; leftover .on('select_node.jstree') handlers stack and fire N times. Always $t.jstree('destroy') and namespace or off('.jstree') your handlers.$t.jstree(true) returns the instance (methods return values); $t.jstree('get_selected') is the getter form. Mixing them up returns undefined and produces "it works in the console but not in code" reports.search requires the plugin in plugins and silently does nothing otherwise — and show_only_matches on a deep tree can hide a match whose ancestors aren't loaded. Test with lazy loading enabled.32px.png, 40px.png, and throbber.gif by relative URL. Vite usually rewrites these, but if spinners/icons 404 (common with CDN copies or custom assetsDir), either copy the sprite next to the emitted CSS or set themes: { icons: false } and style with your own icon font.wholerow plus CSS animation on a 5k-node tree is painful: set core.animation: false, lazy-load, cap children per fetch, and prefer types icons over image sprites. Above a few thousand visible nodes, reconsider the widget.state plugin keys are id-based, so changing id prefixes (or the user's permissions) invalidates saved state and can leave nodes pointing at deleted records — prune on ready.jstree or version the key (categories-v2-{userId}).jQuery is not defined only in npm run dev, add jstree to optimizeDeps.include and keep the shim module as the first import in your entry file.How can I help you explore Laravel packages today?