Nitro

Essentials

Sharing state

Each component's state belongs to that component. When several components need the same state, and need it for longer than any one of them lives, you can keep it in a store that they all share.

On this page

Sharing state with events

Let's start without a store. The examples below are three components that aren't parent and child: a product list, a badge and a cart in a drawer. The product list dispatches an event each time you add a product. The badge and the cart listen for it, and each one keeps its own copy of the cart. Add a few products, then open the cart.

The badge live, in this page View source ↓
Cart 0 · $0
The products live, in this page View source ↓
The drawer live, in this page View source ↓

The cart opens empty. Nitro only creates it when you open the drawer, which is after the events were dispatched, and an event only reaches the components that are on the page at that moment. If you remove an item after adding more, the badge only updates because the cart dispatches an event back.

The code

Browser + server
<?php

namespace App\Nitro\Components;

use Nitro\Attributes\On;
use Nitro\Component;

/** Without a store, the badge keeps its own copy of the count and the total, and events keep it up to date. */
class ShopBadge extends Component
{
    public int $count = 0;

    public int $total = 0;

    #[On('cart-added')]
    public function added(array $product): void
    {
        $this->count++;
        $this->total += $product['price'];
    }

    #[On('cart-removed')]
    public function removed(array $product): void
    {
        $this->count--;
        $this->total -= $product['price'];
    }
}
Browser + server
<?php

namespace App\Nitro\Components;

use Nitro\Attributes\On;
use Nitro\Component;

/** Without a store, the cart keeps its own copy of the items, and it must dispatch an event for each removal. */
class ShopCart extends Component
{
    public array $items = [];

    #[On('cart-added')]
    public function added(array $product): void
    {
        $this->items[] = $product;
    }

    public function remove(int $index): void
    {
        $product = $this->items[$index];
        array_splice($this->items, $index, 1);
        $this->dispatch('cart-removed', $product);
    }
}

This approach has a few problems:

  • Every component keeps a copy. The badge and the cart each store the cart in their own shape, so every change is written twice.
  • Components created later miss events. A component created after an event never hears it. The drawer's cart is one example, and a cart page you visit later is another.
  • A visit loses everything. When you visit another page, its components replace the old ones, and their copies are gone.
  • The server is slower. You could keep the cart in the session instead. However, every add would then need a request to the server, which is exactly what Nitro's browser methods avoid.

Sharing state with a store

Here are the same three components, with the cart kept in a store. A store is one place for a piece of state and the methods that change it, and every component that uses it shares it. Add a few products, then open the cart.

The badge live, in this page View source ↓
Cart 0 · $0
The products live, in this page View source ↓
The drawer live, in this page View source ↓

This time the cart opens with everything you added, because it reads the store as it is whenever it is created. Remove an item and the badge updates too, without any event. You can also visit another page of these docs and come back: the store stays as long as the tab is open.

The code

You write a store like a component without a view, and you put it in app/Nitro/Stores. php artisan make:nitro Cart --store makes one:

app/Nitro/Stores/Cart.php
<?php

namespace App\Nitro\Stores;

use Nitro\Attributes\Computed;
use Nitro\Attributes\Locked;
use Nitro\Attributes\Server;
use Nitro\Attributes\Validate;
use Nitro\Store;

class Cart extends Store
{
    #[Validate(['items' => 'array|max:20', 'items.*.name' => 'required|string|max:40', 'items.*.price' => 'required|integer|min:0'])]
    public array $items = [];

    #[Locked]
    public string $savedAt = '';

    public function mount(): void
    {
        $this->items = session('store-cart.items', []);
        $this->savedAt = session('store-cart.saved_at', '');
    }

    public function add(array $product): void
    {
        $this->items[] = ['name' => $product['name'], 'price' => $product['price']];
    }

    public function remove(int $index): void
    {
        array_splice($this->items, $index, 1);
    }

    #[Computed]
    public function count(): int
    {
        return count($this->items);
    }

    #[Computed]
    public function total(): int
    {
        return array_sum(array_column($this->items, 'price'));
    }

    #[Server]
    public function save(): void
    {
        $this->savedAt = now()->format('H:i:s');

        // This demo keeps the cart in the session. Your app would save it to the database.
        session(['store-cart.items' => $this->items, 'store-cart.saved_at' => $this->savedAt]);
    }
}

Saved in the session, for this demo

So that the example works for every visitor, save() keeps the cart in your session, and mount() reads it back so a reload keeps it. Your app would save the cart to the database instead.

To use a store, give a component a property of the store's type. The component can then call the store's methods from its view:

Browser + server
<?php

namespace App\Nitro\Components;

use App\Nitro\Stores\Cart;
use Nitro\Component;

class StoreBadge extends Component
{
    public Cart $cart;
}
Browser + server
<?php

namespace App\Nitro\Components;

use App\Nitro\Stores\Cart;
use Nitro\Component;

class StoreCart extends Component
{
    public Cart $cart;
}
  • One store, shared. Every component with a public Cart $cart property gets the same store. On the server, there is one store per request, created with its mount(). In the browser, there is one store per tab, and it is kept across visits.
  • Only what reads it renders again. When the store's methods change it, Nitro renders again only the parts that read the store, in every component that uses it.
  • You change it through its methods. Components read the store and call its methods. Writing to a store from outside fails the build. The store's methods run in the browser, and its #[Server] methods, such as save(), run on the server with the store's signed state.

Keeping and sharing a store

By default, a store lasts as long as its tab. Two attributes let you keep it for longer, or share it with the site's other tabs:

app/Nitro/Stores/Cart.php
use Nitro\Attributes\Locked;
use Nitro\Attributes\Persist;
use Nitro\Attributes\Sync;
use Nitro\Store;

#[Persist]                          // Keep the store's values in localStorage.
class Cart extends Store
{
    #[Sync]                         // Share this property with the site's other open tabs.
    public array $items = [];

    #[Locked]                       // The server sets this one, so it is never kept or shared.
    public string $savedAt = '';
}
  • #[Persist] keeps the store's values in the browser's localStorage, so they outlast a reload or a closed tab. When the store is created, Nitro sets the kept values as the browser's own changes.
  • #[Sync] shares the values with the site's other open tabs. A change in one tab is set in the others. A tab you open later starts from the server's state, or, with #[Persist], from the kept values.
  • Where you put them matters. On the store class, they cover every property the browser can change. On a property, they cover only that property. A #[Locked] property belongs to the server, so the store attributes skip it, and putting either attribute on one fails the build.
  • They are for stores only. A component's state belongs to that component, so a component can't use them.

Kept values are checked like any change

The browser holds these values, so a visitor can change them. Nitro treats restored and synced values as ordinary browser changes, and the server checks them on the next call, just like any other change: their type, #[Locked] and #[Validate]. Read anything that must be true, such as prices or stock, from the database in your #[Server] methods.

If the browser has no localStorage (for example, when it is blocked or in a private window) or no BroadcastChannel, the store works as if the attributes weren't there.

Which to use

Keep state in the component that owns it. Move it to a store only when components that aren't parent and child need it, or when it must outlive the component that holds it now. A form's fields, a toggle, or a list that a table filters belong in properties. A cart, the signed-in user's preferences, a wizard that spans several pages, or a queue of notifications belong in a store.