Packages
Event-driven notification system for Ash Framework with multiple transport types
Retired package: Release invalid - Use vulcora organization package
Current section
Files
Jump to
Current section
Files
lib/documentation/topics/counter-broadcasting.md
# Counter Broadcasting
AshDispatch provides **automatic real-time counter updates** through a declarative DSL. Define counters once in your resources, and AshDispatch automatically broadcasts updates to Phoenix Channels when actions complete.
## Why Counter Broadcasting?
Traditional approaches require manual counter management scattered across your codebase:
```elixir
# ❌ Manual approach - error-prone and scattered
def create_order(params) do
{:ok, order} = Orders.create(params)
# Manually update counter
count = Orders.count_pending(order.user_id)
Phoenix.PubSub.broadcast("user:#{order.user_id}", {:counter, :pending_orders, count})
{:ok, order}
end
```
With AshDispatch, counters are **declarative and automatic**:
```elixir
# ✅ AshDispatch approach - declarative and automatic
counters do
counter :pending_orders,
trigger_on: [:create],
counter_name: :pending_orders,
query_filter: [status: :pending],
audience: :user,
invalidates: ["orders"]
end
```
**Benefits:**
- ✅ Define once, works everywhere
- ✅ Automatic broadcasting on action completion
- ✅ Type-safe with compile-time validation
- ✅ Query invalidation hints for frontend
- ✅ Multi-audience support (user, admin, system)
- ✅ Zero boilerplate in actions
---
## Quick Start
### 1. Define Counter in Resource
```elixir
defmodule MyApp.Orders.ProductOrder do
use Ash.Resource,
extensions: [AshDispatch.Resource]
actions do
create :create_from_cart
update :complete
update :cancel
end
# Define counters using DSL
counters do
counter :pending_orders,
trigger_on: [:create, :complete, :cancel],
counter_name: :pending_orders,
query_filter: [status: :pending],
audience: :user,
invalidates: ["orders"]
end
end
```
### 2. Configure Broadcasting
```elixir
# config/config.exs
config :ash_dispatch,
counter_broadcast_fn: {MyAppWeb.UserChannel, :broadcast_counter}
```
### 3. Setup Phoenix Channel
```elixir
defmodule MyAppWeb.UserChannel do
use MyAppWeb, :channel
# Receive counter broadcasts from AshDispatch
def broadcast_counter(user_id, counter_name, value, opts \\ []) do
metadata = Keyword.get(opts, :metadata, %{})
MyAppWeb.Endpoint.broadcast("user:#{user_id}", "counter_updated", %{
counter: counter_name,
value: value,
metadata: metadata
})
end
end
```
### 4. Use on Frontend
```typescript
channel.on("counter_updated", (payload) => {
// Update counter in UI
setCounters(prev => ({
...prev,
[payload.counter]: payload.value
}));
// Invalidate related queries
payload.metadata.invalidate_queries?.forEach(queryKey => {
queryClient.invalidateQueries([queryKey]);
});
});
```
**That's it!** When orders are created, completed, or canceled, the `pending_orders` counter automatically updates in real-time.
---
## Counter DSL Reference
### counter/2
Defines a counter that automatically broadcasts when actions complete.
```elixir
counter :counter_identifier,
trigger_on: [:action1, :action2],
counter_name: :counter_name,
query_filter: [filter_options],
audience: :user | :admin | :partner | :system,
invalidates: ["query_key1", "query_key2"],
resource: MyApp.SomeResource # Optional, defaults to current resource
```
#### Options
**`:trigger_on`** (required) - List of actions that trigger this counter update
```elixir
trigger_on: [:create]
trigger_on: [:create, :update, :destroy]
trigger_on: [:complete, :cancel]
```
**`:counter_name`** (required) - Name of the counter (atom)
```elixir
counter_name: :pending_orders
counter_name: :cart_items
counter_name: :admin_pending_reseller_requests
```
**`:query_filter`** (required) - Filter for counting records
```elixir
query_filter: [status: :pending]
query_filter: [active: true, archived: false]
query_filter: [user_id: {:context, :user_id}]
```
**`:audience`** (required) - Who receives this counter
```elixir
audience: :user # Send to user who triggered the action
audience: :admin # Send to all admins
audience: :partner # Send to partner users
audience: :system # Send to system recipients
```
**`:invalidates`** (optional) - Query keys to invalidate on frontend
```elixir
invalidates: ["orders"]
invalidates: ["orders", "analytics"]
```
**`:resource`** (optional) - Resource to query (defaults to current resource)
```elixir
resource: MyApp.Orders.ProductOrder # Explicit resource
# Defaults to the resource where counter is defined
```
**`:group`** (optional) - Counter group for TypeScript organization
```elixir
group: :orders
group: :tickets
group: :cart
```
Groups are used by the TypeScript generator to create organized type definitions.
**`:global?`** (optional) - Bypass authorization for system-wide counters
```elixir
global?: true # Bypasses policies, no user scoping
global?: false # Default: uses authorization
```
Global counters are useful for admin dashboards that need system-wide totals regardless of policies.
**`:aggregate`** (optional) - Use Ash aggregate instead of query_filter
```elixir
aggregate: :pending_order_count
```
When specified, uses an Ash aggregate defined on the resource instead of running a separate count query.
**`:user_id_path`** (optional) - Path to resolve user_id through relationships
```elixir
user_id_path: [:cart, :user_id] # For CartItem -> Cart -> User
```
---
## Audience Types
### :user - Scoped to Acting User
Broadcasts counter update only to the user who triggered the action.
```elixir
counter :pending_orders,
trigger_on: [:create, :complete],
counter_name: :pending_orders,
query_filter: [status: :pending],
audience: :user
```
**Behavior:**
- Counter query automatically scoped to `user_id` from context
- Only broadcasts to that specific user
- Perfect for user-specific counters (cart items, my orders, my tickets)
**Example:**
```elixir
# User creates an order
Order.create!(%{user_id: "user-123", ...})
# AshDispatch automatically:
# 1. Counts orders WHERE status = :pending AND user_id = "user-123"
# 2. Broadcasts ONLY to "user-123"
```
### :admin - All Admin Users
Broadcasts counter update to all users matching the admin filter.
```elixir
counter :admin_pending_reseller_requests,
trigger_on: [:create, :accept, :decline],
counter_name: :admin_pending_reseller_requests,
query_filter: [status: :pending],
audience: :admin
```
**Configuration:**
```elixir
config :ash_dispatch,
user_module: MyApp.Accounts.User,
recipient_filters: [
audiences: [
admin: [admin: true]
]
]
```
**Behavior:**
- Counter query NOT scoped to user (counts all records)
- Broadcasts to ALL users matching admin filter
- Perfect for admin dashboards
**Example:**
```elixir
# Anyone creates a reseller request
ResellerRequest.create!(...)
# AshDispatch automatically:
# 1. Counts requests WHERE status = :pending (ALL requests)
# 2. Finds all users WHERE admin = true
# 3. Broadcasts to each admin: "user:admin-1", "user:admin-2", etc.
```
### :partner, :system, Custom Audiences
Define custom audiences for specialized counter routing.
```elixir
counter :partner_pending_orders,
trigger_on: [:create],
counter_name: :partner_pending_orders,
query_filter: [status: :pending],
audience: :partner
counter :system_failed_jobs,
trigger_on: [:fail],
counter_name: :system_failed_jobs,
query_filter: [status: :failed],
audience: :system
```
**Configuration:**
```elixir
config :ash_dispatch,
recipient_filters: [
audiences: [
partner: [role: :partner, active: true],
system: [] # No filter, uses system_recipients
]
],
system_recipients: [
%{email: "ops@myapp.com", name: "Operations"}
]
```
---
## Real-World Examples
### E-Commerce Counters
```elixir
defmodule MyApp.Orders.ProductOrder do
use Ash.Resource,
extensions: [AshDispatch.Resource]
counters do
# User sees their own pending orders
counter :user_pending_orders,
trigger_on: [:create, :complete, :cancel],
counter_name: :pending_orders,
query_filter: [status: :pending],
audience: :user,
group: :orders, # TypeScript grouping
invalidates: ["orders", "cart_items"]
# User sees their own processing orders
counter :user_processing_orders,
trigger_on: [:create, :process, :complete],
counter_name: :processing_orders,
query_filter: [status: :processing],
audience: :user,
group: :orders,
invalidates: ["orders"]
# Admins see ALL pending orders (global bypasses policies)
counter :admin_pending_orders,
trigger_on: [:create, :complete, :cancel],
counter_name: :admin_pending_orders,
query_filter: [status: :pending],
audience: :admin,
group: :orders,
global?: true, # No user scoping, bypass auth
invalidates: ["orders", "analytics"]
# Admins see ALL processing orders
counter :admin_processing_orders,
trigger_on: [:create, :process, :complete],
counter_name: :admin_processing_orders,
query_filter: [status: :processing],
audience: :admin,
group: :orders,
global?: true,
invalidates: ["orders", "analytics"]
end
end
```
### Using Ash Aggregates
For complex counting logic, use Ash aggregates:
```elixir
defmodule MyApp.Accounts.User do
use Ash.Resource,
extensions: [AshDispatch.Resource]
aggregates do
# Define aggregate on resource
count :unread_notification_count, :notifications do
filter expr(read_at == nil)
end
end
counters do
# Use aggregate instead of query_filter
counter :unread_notifications,
trigger_on: [:mark_read, :mark_unread],
aggregate: :unread_notification_count, # References aggregate above
audience: :user,
group: :notifications,
invalidates: ["notifications"]
end
end
```
### Support Ticket Counters
```elixir
defmodule MyApp.Tickets.Ticket do
use Ash.Resource,
extensions: [AshDispatch.Resource]
counters do
# User sees their open tickets
counter :user_open_tickets,
trigger_on: [:create, :resolve, :close],
counter_name: :open_tickets,
query_filter: [status: :open],
audience: :user,
invalidates: ["tickets"]
# User sees their in-progress tickets
counter :user_in_progress_tickets,
trigger_on: [:start, :resolve, :close],
counter_name: :in_progress_tickets,
query_filter: [status: :in_progress],
audience: :user,
invalidates: ["tickets"]
# Support team sees ALL open tickets
counter :admin_open_tickets,
trigger_on: [:create, :resolve, :close],
counter_name: :admin_open_tickets,
query_filter: [status: :open],
audience: :admin,
invalidates: ["tickets", "support_dashboard"]
# Support team sees ALL in-progress tickets
counter :admin_in_progress_tickets,
trigger_on: [:start, :resolve, :close],
counter_name: :admin_in_progress_tickets,
query_filter: [status: :in_progress],
audience: :admin,
invalidates: ["tickets", "support_dashboard"]
end
end
```
### Shopping Cart Counter
```elixir
defmodule MyApp.Catalog.Cart do
use Ash.Resource,
extensions: [AshDispatch.Resource]
counters do
# Real-time cart item count
counter :cart_items,
trigger_on: [:add_item, :remove_item, :clear],
counter_name: :cart_items,
query_filter: [], # Count all items in user's cart
audience: :user,
invalidates: ["cart", "checkout"]
end
end
```
---
## Query Invalidation
Counters can specify which frontend queries should be invalidated when they update.
### Why Query Invalidation?
When a counter changes, related data on the frontend may be stale:
```typescript
// Counter says 5 pending orders
const { counters } = useUserChannel();
// => { pending_orders: 5 }
// But the order list query might have old data!
const { data: orders } = useQuery(['orders', 'pending']);
// => Still showing 4 orders (stale!)
```
**Solution:** Counter broadcasts include invalidation hints:
```elixir
counter :pending_orders,
# ...
invalidates: ["orders"] # ← Tell frontend to refetch order queries
```
### Frontend Integration
```typescript
channel.on("counter_updated", (payload) => {
// Update counter
setCounters(prev => ({
...prev,
[payload.counter]: payload.value
}));
// Invalidate related queries
payload.metadata.invalidate_queries?.forEach(queryKey => {
queryClient.invalidateQueries([queryKey]);
});
});
```
**Result:** Counter updates automatically trigger data refetches!
### Multiple Invalidations
```elixir
counter :pending_orders,
trigger_on: [:create],
counter_name: :pending_orders,
query_filter: [status: :pending],
audience: :user,
invalidates: [
"orders", # Refetch order lists
"analytics", # Refetch analytics dashboard
"reports" # Refetch report data
]
```
---
## How It Works
### Compile-Time Transformation
The counter DSL is transformed at compile-time into Ash changes:
```elixir
# You write:
counters do
counter :pending_orders,
trigger_on: [:create],
counter_name: :pending_orders,
query_filter: [status: :pending],
audience: :user
end
# AshDispatch injects:
create :create do
# ... your existing logic
change AshDispatch.Changes.BroadcastCounterUpdate,
counter_name: :pending_orders,
query_filter: [status: :pending],
audience: :user,
invalidates: []
end
```
### Runtime Flow
1. **Action Executes**: User creates an order
2. **Counter Change Runs**: After action success, `BroadcastCounterUpdate` runs
3. **Count Query**: Executes count query with filter
4. **Resolve Recipients**: Determines who receives the update based on audience
5. **Broadcast**: Calls configured `counter_broadcast_fn` for each recipient
```mermaid
sequenceDiagram
participant Action
participant BroadcastCounterUpdate
participant CountQuery
participant Recipients
participant Broadcast
Action->>BroadcastCounterUpdate: After success
BroadcastCounterUpdate->>CountQuery: Count with filter
CountQuery-->>BroadcastCounterUpdate: count = 5
BroadcastCounterUpdate->>Recipients: Resolve for audience
Recipients-->>BroadcastCounterUpdate: [user_ids]
loop For each recipient
BroadcastCounterUpdate->>Broadcast: broadcast_counter(user_id, :pending_orders, 5)
Broadcast->>Phoenix: Phoenix.PubSub
end
```
---
## Auto-Discovery with CounterLoader
Counters defined in the DSL are automatically discovered by `AshDispatch.Helpers.CounterLoader` when users connect to Phoenix Channels.
### How It Works
```elixir
# In your UserChannel
alias AshDispatch.Helpers.CounterLoader
def handle_info(:after_join, socket) do
# Automatically discovers and loads ALL counters
counters = CounterLoader.load_counters_for_user(socket.assigns.user_id)
# => %{pending_orders: 5, cart_items: 3, open_tickets: 2}
push(socket, "initial_state", %{counters: counters})
{:noreply, socket}
end
```
**What happens:**
1. Scans all configured Ash domains
2. Finds all resources with `counters do` blocks
3. Reads counter definitions from DSL
4. Filters counters by user's audiences
5. Executes each counter's query
6. Returns map of counter names to values
**Zero configuration needed!** Just define counters in resources, they're automatically available.
---
## Configuration
### Required Configuration
```elixir
# config/config.exs
config :ash_dispatch,
# Domains to scan for counter definitions
domains: [MyApp.Orders, MyApp.Tickets, MyApp.Catalog],
# User module for audience checking
user_module: MyApp.Accounts.User,
# Function to call when broadcasting counters
counter_broadcast_fn: {MyAppWeb.UserChannel, :broadcast_counter}
```
### Audience Filters
Define how to identify users for each audience:
```elixir
config :ash_dispatch,
recipient_filters: [
audiences: [
admin: [admin: true],
partner: [role: :partner, active: true],
support: [role: :support],
user: [] # All authenticated users
]
]
```
### Complete Example
```elixir
# config/config.exs
config :ash_dispatch,
# Resource discovery
domains: [
MyApp.Orders,
MyApp.Tickets,
MyApp.Catalog,
MyApp.Accounts,
MyApp.Requests
],
# User resolution
user_module: MyApp.Accounts.User,
# Audience filters
recipient_filters: [
audiences: [
admin: [admin: true],
partner: [role: :partner],
user: []
]
],
# Broadcasting
counter_broadcast_fn: {MyAppWeb.UserChannel, :broadcast_counter}
```
---
## Testing
### Test Counter Broadcasts
```elixir
defmodule MyApp.OrdersTest do
use MyApp.DataCase
test "broadcasts pending_orders counter on create" do
user = build(:user) |> create!()
# Create order
order = build(:product_order, %{user_id: user.id, status: :pending})
|> create!()
# Assert counter broadcast
assert_received {:counter_broadcast, ^user.id, :pending_orders, 1, _opts}
end
end
```
### Mock Counter Broadcasting
```elixir
# config/test.exs
config :ash_dispatch,
counter_broadcast_fn: {MyAppTest.MockCounterBroadcaster, :broadcast}
# test/support/mock_counter_broadcaster.ex
defmodule MyAppTest.MockCounterBroadcaster do
def broadcast(user_id, counter_name, value, opts) do
send(self(), {:counter_broadcast, user_id, counter_name, value, opts})
:ok
end
end
```
### Test Counter Queries
```elixir
test "counter query returns correct count" do
user = build(:user) |> create!()
# Create 3 pending orders
build_list(3, :product_order, %{user_id: user.id, status: :pending})
|> Enum.each(&create!/1)
# Load counter
counters = CounterLoader.load_counters_for_user(user.id)
assert counters[:pending_orders] == 3
end
```
---
## Performance Optimization
### Database Indexes
Add indexes for fast counter queries:
```elixir
# In migration
create index(:orders, [:user_id, :status])
create index(:tickets, [:user_id, :status])
create index(:carts, [:user_id])
```
### Counter Query Optimization
Use efficient filters:
```elixir
# ✅ Good - indexed fields
query_filter: [status: :pending]
query_filter: [user_id: {:context, :user_id}, active: true]
# ❌ Avoid - unindexed or complex queries
query_filter: [fragment("expensive_calculation(?) > 10", field(:amount))]
```
### Batch Counter Updates
If multiple actions update the same counter, consider batching:
```elixir
# Instead of broadcasting on every item add/remove
# Broadcast once after bulk operation completes
```
---
## Troubleshooting
### Counter Not Broadcasting
**Check:**
1. `trigger_on` matches action name exactly
2. `counter_broadcast_fn` configured
3. Action completes successfully
4. No errors in logs
**Debug:**
```elixir
# Check counter definitions
AshDispatch.Dsl.Info.counters(MyApp.Orders.ProductOrder)
# Verify broadcast function
Application.get_env(:ash_dispatch, :counter_broadcast_fn)
# Test manually
AshDispatch.Changes.BroadcastCounterUpdate.broadcast_counter_update(
%{user_id: "test"},
:pending_orders,
[status: :pending],
:user,
[]
)
```
### Wrong Counter Value
**Check:**
1. `query_filter` matches intended records
2. Counter scoping (`:user` audience adds user_id filter automatically)
3. Database state
**Debug:**
```elixir
# Test query manually
MyApp.Orders.ProductOrder
|> Ash.Query.filter(status: :pending)
|> Ash.Query.filter(user_id == ^user_id) # For :user audience
|> Ash.count!()
```
### Counter Not Loading on Join
**Check:**
1. Counter audience matches user
2. `:domains` configuration includes resource's domain
3. `:user_module` configured
4. User has correct attributes for audience filter
**Debug:**
```elixir
# Check domains
Application.get_env(:ash_dispatch, :domains)
# Test counter loading manually
CounterLoader.load_counters_for_user(user.id)
```
---
## TypeScript Generation
AshDispatch includes a Mix task to generate TypeScript types and constants for your counters.
### Generate Counter Types
```bash
# Generate to default location
mix ash_dispatch.gen.counter_types
# Specify output path
mix ash_dispatch.gen.counter_types --output lib/my_app_web/static/js/counters.ts
# Generate for specific domain only
mix ash_dispatch.gen.counter_types --only MyApp.Orders
```
### Generated Output
The generator creates a TypeScript file with:
```typescript
// Auto-generated by mix ash_dispatch.gen.counter_types
// Do not edit manually
// Types grouped by counter group
export type OrdersCounters = {
pending_orders: number;
processing_orders: number;
admin_pending_orders: number;
};
export type CartCounters = {
cart_items: number;
};
export type AllCounters = CartCounters & OrdersCounters;
// Counter names organized by source resource
export const COUNTERS = {
cart: {
cart_items: "cart_items",
},
cart_item: {
cart_items: "cart_items", // Same counter, different triggers
},
product_order: {
pending_orders: "pending_orders",
processing_orders: "processing_orders",
admin_pending_orders: "admin_pending_orders",
},
} as const;
// Merged metadata from all sources
export const COUNTER_METADATA = {
cart_items: {
audience: "user",
invalidates: ["cart"],
sources: ["cart", "cart_item"], // Defined in multiple resources
},
pending_orders: {
audience: "user",
invalidates: ["cart_items", "orders"],
sources: ["product_order"],
},
admin_pending_orders: {
audience: "admin",
invalidates: ["orders"],
sources: ["product_order"],
},
} as const;
export type CounterName = "cart_items" | "pending_orders" | "admin_pending_orders";
export function isValidCounter(name: string): name is CounterName {
return name in COUNTER_METADATA;
}
```
### Using Generated Types
```typescript
import {
COUNTERS,
COUNTER_METADATA,
getCounterAccessors,
type AllCounters,
type CounterName
} from "./counters";
// Type-safe counter access with snake_case
const pendingCount = counters[COUNTERS.product_order.pending_orders];
// Auto-generated camelCase accessors (no manual maintenance!)
const accessors = getCounterAccessors(counters);
console.log(accessors.pendingOrders);
console.log(accessors.adminOpenTickets);
// Check if counter should be shown to user
function shouldShowCounter(name: CounterName, isAdmin: boolean) {
const meta = COUNTER_METADATA[name];
return meta.audience === "user" || (meta.audience === "admin" && isAdmin);
}
// Get invalidation queries for a counter
function getInvalidations(name: CounterName): string[] {
return COUNTER_METADATA[name].invalidates;
}
```
### React Hook Integration
The generated types and helpers eliminate almost all manual frontend work.
**Store (one-time setup, never changes):**
```typescript
// lib/stores/use-counter-store.ts
import { create } from 'zustand'
import { DEFAULT_COUNTERS, type AllCounters, type CounterName } from '@/lib/counters'
export type Counters = AllCounters
interface CounterState {
counters: Counters
setCounters: (counters: Partial<Counters>) => void
setCounter: (key: CounterName, value: number) => void
resetCounters: () => void
}
export const useCounterStore = create<CounterState>()((set) => ({
counters: DEFAULT_COUNTERS,
setCounters: (newCounters) => set((state) => ({ counters: { ...state.counters, ...newCounters } })),
setCounter: (key, value) => set((state) => ({ counters: { ...state.counters, [key]: value } })),
resetCounters: () => set({ counters: DEFAULT_COUNTERS }),
}))
```
**Hook (one-time setup, never changes):**
```typescript
// hooks/use-counters.ts
import { useCounterStore } from '@/lib/stores/use-counter-store'
import { getCounterAccessors } from '@/lib/counters'
export function useCounters() {
const counters = useCounterStore((state) => state.counters)
return {
...getCounterAccessors(counters),
counters,
}
}
```
**Usage in components:**
```typescript
function MyComponent() {
const { cartItems, pendingOrders, adminOpenTickets } = useCounters()
// Full TypeScript autocomplete!
}
```
### Zero-Maintenance Workflow
When you add new counters in Elixir:
1. Add counter to resource DSL
2. Run `mix ash_dispatch.gen.counter_types --output path/to/counters.ts`
3. **Done!** No frontend changes needed
The generated file includes:
- `DEFAULT_COUNTERS` - Store initialization
- `AllCounters` type - Full type definition
- `CounterAccessors` type - camelCase return type
- `getCounterAccessors()` - Converts snake_case to camelCase
- `COUNTER_METADATA` - Invalidates, audience, sources
### Multi-Resource Counters
When the same counter is defined in multiple resources (e.g., `cart_items` in both Cart and CartItem), the generator:
1. **Merges invalidates** - Union of all invalidates from all sources
2. **Tracks sources** - Shows which resources define the counter
3. **Validates consistency** - Warns if audience differs between resources
This allows different actions to trigger the same counter update:
```elixir
# In Cart resource
counter :cart_items,
trigger_on: [:add_item, :clear], # Triggered by cart actions
audience: :user,
group: :cart
# In CartItem resource
counter :cart_items,
trigger_on: [:create, :destroy], # Triggered by item actions
audience: :user,
group: :cart
```
Both will broadcast the same `cart_items` counter but from different action triggers.
---
## Counter Store as Single Source of Truth
When building features that need real-time counts, **always read from the counter store** rather than maintaining separate state.
### Example: Notification Badge
```typescript
// ❌ Wrong - separate state that won't sync
export function useNotifications() {
const [unreadCount, setUnreadCount] = useState(0) // Gets out of sync!
// ...
}
// ✅ Correct - read from counter store
export function useNotifications() {
const unreadCount = useCounterStore(
(state) => state.counters.unread_notifications
)
// unreadCount automatically updates via WebSocket
}
```
### Why This Matters
1. **Real-time sync** - Counter store receives broadcasts from all tabs/sessions
2. **No duplication** - Single source means no sync bugs
3. **Type-safe** - Counter names validated by generated types
### Pattern for Feature Hooks
```typescript
// Any feature that needs a real-time count
export function useFeatureWithCount() {
const featureStore = useFeatureStore()
const count = useCounterStore((state) => state.counters.my_counter)
return {
items: featureStore.items,
count, // From counter store, not feature store
// ...actions
}
}
```
---
## LiveView vs AshTypescript/SPA
This TypeScript counter system is designed for **Single Page Applications** (React, Vue, etc.) using AshTypescript.
### For SPA/AshTypescript Apps
Use the full TypeScript integration:
- Generated types and store
- Phoenix channels for real-time updates
- Zustand/Redux for state management
### For LiveView Apps
LiveView already has real-time updates via its socket connection. The counter DSL and broadcasting still work, but frontend consumption differs:
```elixir
# LiveView can use PubSub directly
def handle_info({:counter_updated, counter, value}, socket) do
{:noreply, assign(socket, counter, value)}
end
```
The Elixir-side counter DSL, broadcasting, and Phoenix channel helpers work identically - only the frontend consumption layer differs.
---
## Next Steps
- [Phoenix Channel Integration](phoenix-integration.md) - Setup channels with helpers
- [Configuration](configuration.md) - Complete configuration reference