RizzyUI

Working with State and Reactivity

You've learned how to create a component with x-data and pass initial props. Now, let's explore how to make your components more dynamic. This page introduces three core reactive tools: watching state with $watch, reacting automatically with x-effect, and sharing global state with Alpine.store().

$watch: Responding to State Changes

Sometimes, you need to perform an action whenever a piece of your component's state changes. The $watch magic property is perfect for this. It lets you "watch" a property and run a function whenever its value is updated. Common use cases include validating user input as they type or auto-saving draft content.

Example: A Character Counter

Let's create a textarea with a character counter that updates in real-time. Note that since RzInputTextArea is a form component, it must be placed within an EditForm.

CharacterCounter.razor
@attribute [RzAlpineCodeBehind]
@using RizzyUI.Docs.Models

<EditForm Model="@_model">
    <RzAlpineComponent For="this" Name="charCounter" AsChild>
      <div class="w-full">
        <RzInputTextArea For="@(() => _model.Message)" class="w-full min-h-24" x-model="message" />
        <p class="text-sm text-muted-foreground mt-2 text-right">
          Character count: <span x-text="count"></span>
        </p>
      </div>
    </RzAlpineComponent>
</EditForm>

@code {
    private class CharacterCounterModel
    {
        public string Message { get; set; } = string.Empty;
    }

    private CharacterCounterModel _model = new();
}
CharacterCounter.razor.js
export default () => ({
  message: '',
  count: 0,

  init() {
    // Watch the 'message' property for changes.
    this.$watch('message', (newValue) => {
      this.count = newValue.length;
    });
  }
});

$refs: Accessing DOM Elements

While reactive data binding is preferred, you sometimes need a direct handle to a DOM element. The $refs magic property allows you to access any element within your component that has an x-ref attribute.

In RizzyUI, $refs is the gateway to component orchestration. By placing an x-ref on another RizzyUI component (like <RzDialog>), you can get its root element and then use the Rizzy.$data() helper to access its Alpine instance and API. This bridges the gap between your Razor components and Alpine, letting you call Alpine APIs on other RizzyUI components.

ModalController.razor
@attribute [RzAlpineCodeBehind]

@using RizzyUI
<RzAlpineComponent For="this" Name="modalController">
  <RzButton x-on:click="openModal">Open Modal</RzButton>
  <RzDialog Title="My Modal" x-ref="myModal" />
</RzAlpineComponent>
ModalController.razor.js
export default () => ({
  modalInstance: null,

  async init() {
    // !IMPORTANT! Allow a tick for modal to transport to body (needed by RzDialog)
    // If we don't wait the tick then Rizzy.$data() won't be able to resolve the dialog. Note that waiting a tick
    // is only needed to resolve data for components that move themselves in the DOM from template 
    // (like modals, popovers, etc).
    await Alpine.nextTick();
  
    // Get the Alpine instance of the RzDialog component
    this.modalInstance = Rizzy.$data(this.$refs.myModal);
  },

  openModal() {
    if (this.modalInstance) {
      this.modalInstance.openModal();
    }
  }
});

x-effect: Automatically Reacting to Changes

x-effect is like $watch, but it automatically tracks any reactive properties you use inside it. Whenever any of those dependencies change, the effect re-runs.

A great use case is persisting form data to localStorage as the user types, and showing a status message.

FormSaver.razor.js
export default () => ({
  name: '',
  email: '',
  status: 'Ready',

  init() {
    // ... load saved data on initialization ...

    // This effect will run whenever 'name' or 'email' changes
    this.$effect(() => {
      const dataToSave = { name: this.name, email: this.email };
      localStorage.setItem('formData', JSON.stringify(dataToSave));
      
      this.status = 'Saving...';
      setTimeout(() => { this.status = 'Saved!' }, 500);
    });
  }
});

Global State with Alpine.store()

Sometimes, you need to share state between components that are not parent and child. For this, Alpine provides Alpine.store(). A store is a global reactive object that any component on the page can access using the $store magic property.

This is perfect for application-wide state like a shopping cart count, user authentication status, or the current theme. The <RzDarkModeToggle> component itself uses this pattern internally.

Example: A Global Notification Store

Let's create a simple store to show and hide a site-wide notification banner.

1. Register the Store

This script typically lives in your global site bundle (e.g., site.js) so it runs once at startup.

site.js
document.addEventListener('alpine:init', () => {
  Alpine.store('notifications', {
    message: '',
    visible: false,
    show(newMessage) {
      this.message = newMessage;
      this.visible = true;
    },
    hide() {
      this.visible = false;
    }
  });
});

2. Create the Notification Banner

This component reads directly from the store. It can be placed in your main layout file.

NotificationBanner.razor
<div x-data x-show="$store.notifications.visible" x-transition class="...">
  <p x-text="$store.notifications.message"></p>
  <RzButton x-on:click="$store.notifications.hide()">Close</RzButton>
</div>

3. Trigger the Notification

Any other component on the page can now trigger the notification.

UpdateProfileForm.razor
<RzAlpineComponent For="this" Name="updateProfile">
  <RzButton x-on:click="$store.notifications.show('Your profile has been updated!')">
    Update Profile
  </RzButton>
</RzAlpineComponent>

Next Steps

You now have the tools to manage both local and global state, react to changes, and interact with the DOM. Next, we'll look at the Lifecycle and Initialization of Alpine components to understand exactly when and how your code runs.