Skip to main content
5Stack uses Pinia for centralized state management with Vue 3’s Composition API. Stores are reactive, type-safe, and integrate seamlessly with GraphQL subscriptions.

Pinia Overview

Pinia is the official state management library for Vue 3, offering:

Composition API

Define stores using setup() syntax with full TypeScript support

Reactive State

Automatic reactivity using Vue’s ref() and computed()

DevTools Integration

Time-travel debugging and state inspection

HMR Support

Hot Module Replacement during development

Store Architecture

The application has several specialized stores:
stores/
├── AuthStore.ts              # Authentication and user session
├── MatchmakingStore.ts       # Matchmaking, lobbies, and friends
├── MatchLobbyStore.ts        # Active matches and tournaments
├── NotificationStore.ts      # Notifications and alerts
├── SearchStore.ts            # Global search state
├── FileManagerStore.ts       # File browser for demos/logs
├── ApplicationSettings.ts    # App configuration and settings
└── TeamLobbyStore.ts         # Team management

AuthStore

The AuthStore manages user authentication and session state.

Store Definition

// stores/AuthStore.ts
import { ref, computed } from "vue";
import { defineStore, acceptHMRUpdate } from "pinia";
import { generateQuery, generateSubscription } from "~/graphql/graphqlGen";
import { meFields } from "~/graphql/meGraphql";
import getGraphqlClient from "~/graphql/getGraphqlClient";
import type { GraphQLTypes, InputType } from "~/generated/zeus";

export const useAuthStore = defineStore('auth', () => {
  // State
  const me = ref<InputType<GraphQLTypes['players'], typeof meFields>>();
  const hasDiscordLinked = ref<boolean>(false);

  // Initialize other stores
  useSearchStore();
  useMatchmakingStore();
  useNotificationStore();
  useApplicationSettingsStore();

  // Actions and getters defined below...
  
  return {
    me,
    getMe,
    hasDiscordLinked,
    isUser,
    isAdmin,
    isRoleAbove,
  };
});
The store initializes dependent stores (matchmaking, notifications, etc.) to ensure proper setup order.

Role-Based Access Control

const roleOrder = [
  e_player_roles_enum.user,
  e_player_roles_enum.verified_user,
  e_player_roles_enum.streamer,
  e_player_roles_enum.match_organizer,
  e_player_roles_enum.tournament_organizer,
  e_player_roles_enum.administrator,
];

function isRoleAbove(role: e_player_roles_enum) {
  if (!me.value) return false;
  
  const meRoleIndex = roleOrder.indexOf(me.value.role);
  const roleIndex = roleOrder.indexOf(role);
  
  return meRoleIndex >= roleIndex;
}

// Computed role checks
const isAdmin = computed(
  () => me.value?.role === e_player_roles_enum.administrator
);

const isMatchOrganizer = computed(
  () => me.value?.role === e_player_roles_enum.match_organizer
);

Fetching User Data

async function getMe(): Promise<boolean> {
  // Helper function for subscriptions
  function subscribeToMe(steam_id: string, callback: () => void) {
    const subscription = getGraphqlClient().subscribe({
      query: generateSubscription({
        players_by_pk: [
          { steam_id },
          meFields,
        ],
      }),
    });

    subscription.subscribe({
      next: ({ data }) => {
        me.value = data?.players_by_pk;
        
        if (me.value) {
          // Subscribe to user-specific data
          useMatchLobbyStore().subscribeToMyMatches();
          useMatchLobbyStore().subscribeToLiveMatches();
          
          // Conditionally subscribe based on role
          if (isRoleAbove(e_player_roles_enum.match_organizer)) {
            useMatchLobbyStore().subscribeToManagingMatches();
          }
        }
        
        callback();
      },
    });
  }

  return await new Promise(async (resolve) => {
    try {
      // Initial query to get basic user info
      const response = await getGraphqlClient().query({
        query: generateQuery({
          me: {
            role: true,
            steam_id: true,
            discord_id: true,
          },
        }),
        fetchPolicy: 'network-only',
      });

      if (!response.data.me) {
        resolve(false);
        return;
      }

      // Connect WebSocket
      socket.connect();
      hasDiscordLinked.value = !!response.data.me.discord_id;

      // Wait for WebSocket to connect
      const wsClient = useNuxtApp().$wsClient;
      await new Promise<void>((resolveWs) => {
        const timeout = setTimeout(() => {
          dispose();
          resolveWs();
        }, 10000);
        
        const dispose = wsClient.on('connected', () => {
          clearTimeout(timeout);
          dispose();
          resolveWs();
        });
      });

      // Subscribe to real-time updates
      subscribeToMe(response.data.me.steam_id, () => {
        resolve(true);
      });
    } catch (error) {
      console.warn('auth failure', error);
      resolve(false);
    }
  });
}
  1. Query basic user info (role, steam_id, discord_id)
  2. If user exists, connect WebSocket for subscriptions
  3. Wait for WebSocket connection to establish
  4. Subscribe to real-time user updates via GraphQL subscription
  5. Initialize role-specific subscriptions (matches, tournaments, etc.)

Hot Module Replacement

if (import.meta.hot) {
  import.meta.hot.accept(acceptHMRUpdate(useAuthStore, import.meta.hot));
}

MatchmakingStore

The MatchmakingStore handles matchmaking queues, lobbies, friends, and latency testing.

State Definition

// stores/MatchmakingStore.ts
export const useMatchmakingStore = defineStore('matchmaking', () => {
  // Online players
  const playersOnline = ref([]);
  const onlinePlayerSteamIds = ref<string[]>([]);

  // Matchmaking state
  const joinedMatchmakingQueues = ref<{
    details?: {
      totalInQueue: number;
      type: e_match_types_enum;
      regions: Array<string>;
    };
    confirmation?: {
      matchId: string;
      isReady: boolean;
      expiresAt: string;
      confirmed: number;
      confirmationId: string;
      type: e_match_types_enum;
      region: string;
      players: number;
    };
  }>({
    details: undefined,
    confirmation: undefined,
  });

  // Friends and lobbies
  const friends = ref([]);
  const lobbies = ref([]);
  const matchInvites = ref([]);

  // Region/latency state
  const regionStats = ref<
    Partial<Record<string, Partial<Record<e_match_types_enum, number>>>>
  >({});
  const latencies = ref(new Map<string, number[]>());
  const storedRegions = ref<string[]>([]);
  const playerMaxAcceptableLatency = ref(75);

  // Actions defined below...
  
  return {
    friends,
    onlineFriends,
    lobbies,
    currentLobby,
    latencies,
    refreshLatencies,
    inviteToLobby,
    // ...
  };
});

Real-Time Friends List

const subscribeToFriends = async (mySteamId: bigint) => {
  const subscription = getGraphqlClient().subscribe({
    query: generateSubscription({
      my_friends: [
        {},
        {
          elo: true,
          name: true,
          role: true,
          steam_id: true,
          avatar_url: true,
          status: true,
          player: {
            is_in_lobby: true,
            is_in_another_match: true,
            lobby_players: [
              {
                limit: 1,
                where: {
                  lobby: {
                    _not: {
                      players: {
                        steam_id: { _eq: $('mySteamId', 'bigint!') },
                      },
                    },
                    access: {
                      _in: [
                        e_lobby_access_enum.Friends,
                        e_lobby_access_enum.Open,
                      ],
                    },
                  },
                },
              },
              {
                lobby_id: true,
                lobby: {
                  id: true,
                  players: [
                    {},
                    {
                      player: {
                        name: true,
                        steam_id: true,
                        avatar_url: true,
                      },
                    },
                  ],
                },
              },
            ],
          },
        },
      ],
    }),
    variables: { mySteamId },
  });

  subscription.subscribe({
    next: ({ data }) => {
      friends.value = data.my_friends;
    },
  });
};

Computed Values

const onlineFriends = computed(() => {
  return friends.value?.filter((friend: any) => {
    if (friend.status === 'Pending') return false;
    return onlinePlayerSteamIds.value.includes(friend.steam_id);
  });
});

const currentLobby = computed(() => {
  return lobbies.value.find((lobby: any) => {
    return lobby.id === useAuthStore().me?.current_lobby_id;
  });
});

const lobbyInvites = computed(() => {
  const me = useAuthStore().me;
  if (!lobbies.value) return [];
  return lobbies.value.filter((lobby: any) => {
    return lobby.id !== me?.current_lobby_id;
  });
});

Watchers for Reactive Updates

// Watch for auth changes
watch(
  () => useAuthStore().me,
  (me) => {
    if (me) {
      subscribeToFriends(me.steam_id);
      subscribeToMatchInvites(me.steam_id);
      subscribeToLobbies(me.steam_id);
    }
  },
  { immediate: true },
);

// Watch for online player changes
watch(onlinePlayerSteamIds, (newSteamIds, oldSteamIds) => {
  if (
    newSteamIds.length !== oldSteamIds.length ||
    !newSteamIds.every((id, index) => id === oldSteamIds[index])
  ) {
    queryPlayers();
  }
});

Latency Testing with WebRTC

const isRefreshing = ref(false);

async function refreshLatencies() {
  if (isRefreshing.value) return;
  
  isRefreshing.value = true;
  resetLatencies();
  
  await Promise.all(
    useApplicationSettingsStore().availableRegions.map((region) =>
      getLatency(region.value)
    )
  );
  
  isRefreshing.value = false;
}

async function getLatency(region: string) {
  return new Promise(async (resolve) => {
    setTimeout(() => resolve(undefined), 5000);
    
    try {
      const buffer = new Uint8Array([0x01]).buffer;

      const datachannel = await webrtc.connect(region, (data) => {
        if (data === "") {
          datachannel.send(buffer);
          return;
        }

        const event = JSON.parse(data);
        if (event.type === "latency-results") {
          datachannel.close();
          latencies.value.set(region, event.data);
        }
      });

      datachannel.send("latency-test");
    } catch (error) {
      console.error(`Failed to get latency for ${region}`, error);
      resolve(undefined);
    }
  });
}

LocalStorage Integration

// Load preferred regions from localStorage
const localStoragePreferredRegions = localStorage.getItem(PREFERRED_REGIONS_KEY);
const storedRegions = ref<string[]>(
  localStoragePreferredRegions
    ? JSON.parse(localStoragePreferredRegions)
    : []
);

// Load latencies for each region
useApplicationSettingsStore().availableRegions.forEach((region) => {
  const savedLatency = localStorage.getItem(REGION_LATENCY_PREFIX + region.value);
  if (savedLatency) {
    latencies.value.set(region.value, JSON.parse(savedLatency));
  }
});

function togglePreferredRegion(region: string) {
  const index = storedRegions.value.indexOf(region);
  if (index !== -1) {
    storedRegions.value.splice(index, 1);
  } else {
    storedRegions.value.push(region);
  }
  localStorage.setItem(
    PREFERRED_REGIONS_KEY,
    JSON.stringify(storedRegions.value.filter(Boolean))
  );
}

Using Stores in Components

Setup Syntax

<script setup lang="ts">
import { useAuthStore } from '~/stores/AuthStore';
import { useMatchmakingStore } from '~/stores/MatchmakingStore';

const authStore = useAuthStore();
const matchmakingStore = useMatchmakingStore();

// Access state directly (reactive)
const userName = computed(() => authStore.me?.name);
const onlineFriends = computed(() => matchmakingStore.onlineFriends);

// Call actions
const handleInvite = async (steamId: string) => {
  await matchmakingStore.inviteToLobby(steamId);
};
</script>

<template>
  <div>
    <p>Welcome, {{ userName }}</p>
    <p>{{ onlineFriends.length }} friends online</p>
  </div>
</template>

Options API (if needed)

<script lang="ts">
export default {
  computed: {
    me() {
      return useAuthStore().me;
    },
  },
};
</script>

Store Patterns

Pattern: Initialize on Auth

Many stores initialize subscriptions when the user logs in:
watch(
  () => useAuthStore().me,
  (me) => {
    if (me) {
      // Start subscriptions
      subscribeToData(me.steam_id);
    }
  },
  { immediate: true },
);

Pattern: Cross-Store Communication

// Store A reads from Store B
const currentLobby = computed(() => {
  const me = useAuthStore().me;  // Access another store
  return lobbies.value.find(l => l.id === me?.current_lobby_id);
});

Pattern: GraphQL Subscription → Reactive State

function subscribeToData() {
  const subscription = getGraphqlClient().subscribe({
    query: generateSubscription({ /* ... */ }),
  });

  subscription.subscribe({
    next: ({ data }) => {
      // Update reactive state
      myState.value = data.something;
    },
  });
}

Best Practices

Always type your state using generated GraphQL types:
import type { InputType, GraphQLTypes } from '~/generated/zeus';

const me = ref<InputType<GraphQLTypes['players'], typeof meFields>>();
Use computed() for values derived from state:
const onlineFriends = computed(() => {
  return friends.value.filter(f => 
    onlinePlayerSteamIds.value.includes(f.steam_id)
  );
});
Use watch() to react to state changes:
watch(() => authStore.me, (me) => {
  if (me) {
    initializeSubscriptions();
  }
});
Unsubscribe when no longer needed:
let unsubscribe: (() => void) | undefined;

function subscribe() {
  const sub = getGraphqlClient().subscribe(/* ... */);
  unsubscribe = sub.subscribe(/* ... */).unsubscribe;
}

onUnmounted(() => {
  unsubscribe?.();
});

Next Steps

GraphQL API

Learn how to query and subscribe to data

Components

Build reactive components with stores

Build docs developers (and LLMs) love