diff --git a/VEZA_COMPLETE_MVP_TODOLIST.json b/VEZA_COMPLETE_MVP_TODOLIST.json
index 9b52ae895..ac0083db9 100644
--- a/VEZA_COMPLETE_MVP_TODOLIST.json
+++ b/VEZA_COMPLETE_MVP_TODOLIST.json
@@ -8986,7 +8986,7 @@
"description": "Normalize nested state structures for better performance",
"owner": "frontend",
"estimated_hours": 6,
- "status": "todo",
+ "status": "completed",
"files_involved": [],
"implementation_steps": [
{
@@ -9007,7 +9007,8 @@
"Unit tests",
"Integration tests"
],
- "notes": ""
+ "notes": "Implemented state normalization utility and applied to LibraryStore. Created normalization utilities (normalize, denormalize, addToNormalized, updateInNormalized, removeFromNormalized, etc.) and updated LibraryStore to use normalized state structure (byId + allIds pattern) for better performance. Updated selectors to convert normalized state to arrays for compatibility. Updated tests and components to use new selectors.",
+ "completed_at": "2025-12-25T14:10:10.444700Z"
},
{
"id": "FE-STATE-010",
diff --git a/apps/web/src/features/dashboard/pages/DashboardPage.tsx b/apps/web/src/features/dashboard/pages/DashboardPage.tsx
index d417060c0..dc4c54b6f 100644
--- a/apps/web/src/features/dashboard/pages/DashboardPage.tsx
+++ b/apps/web/src/features/dashboard/pages/DashboardPage.tsx
@@ -1,6 +1,6 @@
import { useEffect } from 'react';
import { useAuthStore } from '@/stores/auth';
-import { useLibraryStore } from '@/stores/library';
+import { useLibraryItems, useLibraryActions, useLibraryStatus } from '@/utils/storeSelectors';
import {
Card,
CardContent,
@@ -13,7 +13,10 @@ import { Music, MessageSquare, Library, Users, Heart } from 'lucide-react';
function DashboardPage() {
const { user } = useAuthStore();
- const { items, fetchItems, isLoading } = useLibraryStore();
+ // FE-STATE-009: Use selector that returns denormalized array
+ const items = useLibraryItems();
+ const { fetchItems } = useLibraryActions();
+ const { isLoading } = useLibraryStatus();
useEffect(() => {
fetchItems({ limit: 5 });
diff --git a/apps/web/src/pages/DashboardPage.tsx b/apps/web/src/pages/DashboardPage.tsx
index bf333204a..286acdd78 100644
--- a/apps/web/src/pages/DashboardPage.tsx
+++ b/apps/web/src/pages/DashboardPage.tsx
@@ -1,7 +1,7 @@
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '@/stores/auth';
-import { useLibraryStore } from '@/stores/library';
+import { useLibraryItems, useLibraryActions, useLibraryStatus } from '@/utils/storeSelectors';
import { useDashboard } from '@/features/dashboard/hooks/useDashboard';
import {
Card,
@@ -21,7 +21,10 @@ import { fr } from 'date-fns/locale';
*/
export function DashboardPage() {
const { user } = useAuthStore();
- const { items, fetchItems, isLoading: isLoadingLibrary } = useLibraryStore();
+ // FE-STATE-009: Use selector that returns denormalized array
+ const items = useLibraryItems();
+ const { fetchItems } = useLibraryActions();
+ const { isLoading: isLoadingLibrary } = useLibraryStatus();
const { stats, recentActivity, isLoading: isLoadingDashboard } = useDashboard();
const navigate = useNavigate();
@@ -29,9 +32,6 @@ export function DashboardPage() {
fetchItems({ limit: 5 });
}, [fetchItems]);
- // Sécuriser items pour s'assurer que c'est toujours un tableau
- const safeItems = Array.isArray(items) ? items : [];
-
// FE-PAGE-001: Format stats with real data
const formatNumber = (num: number): string => {
if (num >= 1000000) {
@@ -233,7 +233,7 @@ export function DashboardPage() {
) : (
- {safeItems.slice(0, 3).map((item) => (
+ {items.slice(0, 3).map((item) => (
@@ -248,7 +248,7 @@ export function DashboardPage() {
))}
- {safeItems.length === 0 && (
+ {items.length === 0 && (
Aucune piste dans votre bibliothèque
diff --git a/apps/web/src/stores/library.ts b/apps/web/src/stores/library.ts
index 464b48bb1..5aed2a810 100644
--- a/apps/web/src/stores/library.ts
+++ b/apps/web/src/stores/library.ts
@@ -2,11 +2,23 @@ import { create } from 'zustand';
import { persist, devtools } from 'zustand/middleware';
import { apiClient } from '@/services/api/client';
import { undoRedo } from '@/utils/undoRedo';
+import {
+ normalize,
+ denormalize,
+ addToNormalized,
+ updateInNormalized,
+ removeFromNormalized,
+ replaceNormalized,
+ mergeNormalized,
+ createEmptyNormalized,
+ type NormalizedState,
+} from '@/utils/stateNormalization';
import type { LibraryItem, PaginatedResponse, ApiError } from '@/types';
interface LibraryState {
- items: LibraryItem[];
- favorites: LibraryItem[];
+ // FE-STATE-009: Normalized state for better performance
+ items: NormalizedState
;
+ favorites: NormalizedState;
isLoading: boolean;
error: ApiError | null;
pagination: {
@@ -47,9 +59,9 @@ export const useLibraryStore = create ({
- // État initial
- items: [],
- favorites: [],
+ // État initial - FE-STATE-009: Normalized state
+ items: createEmptyNormalized(),
+ favorites: createEmptyNormalized(),
isLoading: false,
error: null,
pagination: {
@@ -88,8 +100,9 @@ export const useLibraryStore = create(),
error: error as ApiError,
isLoading: false,
});
@@ -130,15 +143,16 @@ export const useLibraryStore = create(),
error: error as ApiError,
isLoading: false,
});
@@ -162,8 +176,9 @@ export const useLibraryStore = create ({
- items: [newItem, ...state.items],
+ items: addToNormalized(state.items, newItem, 0),
isLoading: false,
error: null,
}));
@@ -177,36 +192,62 @@ export const useLibraryStore = create {
const previousState = get();
- const item = previousState.items.find((i) => i.id === itemId);
+ const item = previousState.items.byId[itemId];
const newFavoriteStatus = item ? !item.is_favorite : true;
- // Optimistic update
- set((state) => ({
- items: state.items.map((i) =>
- i.id === itemId ? { ...i, is_favorite: newFavoriteStatus } : i,
- ),
- favorites: newFavoriteStatus && item
- ? [...state.favorites.filter((i) => i.id !== itemId), { ...item, is_favorite: true }]
- : state.favorites.filter((i) => i.id !== itemId),
- }));
+ // Optimistic update - FE-STATE-009: Update in normalized state
+ set((state) => {
+ const updatedItems = updateInNormalized(state.items, itemId, {
+ is_favorite: newFavoriteStatus,
+ });
+
+ let updatedFavorites = state.favorites;
+ if (newFavoriteStatus && item) {
+ // Add to favorites
+ const favoriteItem = { ...item, is_favorite: true };
+ updatedFavorites = addToNormalized(
+ removeFromNormalized(state.favorites, itemId),
+ favoriteItem,
+ );
+ } else {
+ // Remove from favorites
+ updatedFavorites = removeFromNormalized(state.favorites, itemId);
+ }
+
+ return {
+ items: updatedItems,
+ favorites: updatedFavorites,
+ };
+ });
try {
const response = await apiClient.post(`/tracks/${itemId}/favorite`);
const updatedItem = response.data;
- // Apply server response
- set((state) => ({
- items: state.items.map((i) =>
- i.id === itemId
- ? { ...i, is_favorite: updatedItem.is_favorite }
- : i,
- ),
- favorites: updatedItem.is_favorite
- ? [...state.favorites.filter((i) => i.id !== itemId), updatedItem]
- : state.favorites.filter((i) => i.id !== itemId),
- }));
+ // Apply server response - FE-STATE-009: Update in normalized state
+ set((state) => {
+ const updatedItems = updateInNormalized(state.items, itemId, {
+ is_favorite: updatedItem.is_favorite,
+ });
+
+ let updatedFavorites = state.favorites;
+ if (updatedItem.is_favorite) {
+ updatedFavorites = addToNormalized(
+ removeFromNormalized(state.favorites, itemId),
+ updatedItem,
+ );
+ } else {
+ updatedFavorites = removeFromNormalized(state.favorites, itemId);
+ }
+
+ return {
+ items: updatedItems,
+ favorites: updatedFavorites,
+ };
+ });
} catch (error: any) {
// Rollback on error
set(() => previousState);
@@ -216,13 +257,14 @@ export const useLibraryStore = create {
const previousState = get();
- // Optimistic update - remove item immediately
+ // Optimistic update - remove item immediately from normalized state
set((state) => ({
- items: state.items.filter((item) => item.id !== itemId),
- favorites: state.favorites.filter((item) => item.id !== itemId),
+ items: removeFromNormalized(state.items, itemId),
+ favorites: removeFromNormalized(state.favorites, itemId),
}));
try {
@@ -247,10 +289,10 @@ export const useLibraryStore = create set({ error }),
- clearItems: () =>
+ clearItems: () => {
set({
- items: [],
- favorites: [],
+ items: createEmptyNormalized(),
+ favorites: createEmptyNormalized(),
pagination: {
page: 1,
limit: 20,
@@ -258,6 +300,8 @@ export const useLibraryStore = create {
// Track changes to items and favorites (not loading/error states)
+ // FE-STATE-009: Compare normalized state structures
return (
JSON.stringify(state.items) !== JSON.stringify(prevState?.items) ||
JSON.stringify(state.favorites) !== JSON.stringify(prevState?.favorites)
);
},
- },
- ),
- {
- name: 'library-storage',
+ },
+ ),
+ {
+ name: 'library-storage',
partialize: (state) => ({
// FE-STATE-001: Persist favorites and filters for offline support
+ // FE-STATE-009: Persist normalized favorites state
favorites: state.favorites,
filters: state.filters,
// Don't persist items and pagination as they should be fetched fresh
diff --git a/apps/web/src/test/stores.test.ts b/apps/web/src/test/stores.test.ts
index b03cc8a9b..46ada613a 100644
--- a/apps/web/src/test/stores.test.ts
+++ b/apps/web/src/test/stores.test.ts
@@ -4,6 +4,7 @@ import { useUIStore } from '@/stores/ui';
import { useChatStore } from '@/stores/chat';
import { useLibraryStore } from '@/stores/library';
import { usePlayerStore } from '@/stores/player';
+import { createEmptyNormalized } from '@/utils/stateNormalization';
// Mock localStorage
const localStorageMock = {
@@ -153,8 +154,9 @@ describe('Zustand Stores', () => {
describe('Library Store', () => {
it('should have initial state', () => {
const state = useLibraryStore.getState();
- expect(state.items).toEqual([]);
- expect(state.favorites).toEqual([]);
+ // FE-STATE-009: Normalized state structure
+ expect(state.items).toEqual(createEmptyNormalized());
+ expect(state.favorites).toEqual(createEmptyNormalized());
expect(state.isLoading).toBe(false);
expect(state.error).toBeNull();
});
diff --git a/apps/web/src/utils/stateNormalization.ts b/apps/web/src/utils/stateNormalization.ts
new file mode 100644
index 000000000..55b520815
--- /dev/null
+++ b/apps/web/src/utils/stateNormalization.ts
@@ -0,0 +1,247 @@
+/**
+ * State Normalization Utilities
+ * FE-STATE-009: Normalize nested state structures for better performance
+ *
+ * Provides utilities to normalize nested state structures (arrays of objects)
+ * into flat structures with indexed lookups (byId + allIds pattern).
+ * This improves performance by enabling O(1) lookups instead of O(n) array searches.
+ */
+
+/**
+ * Normalized state structure
+ * Instead of storing items as arrays: [item1, item2, item3]
+ * We store them as: { byId: { id1: item1, id2: item2, id3: item3 }, allIds: ['id1', 'id2', 'id3'] }
+ */
+export interface NormalizedState {
+ byId: Record;
+ allIds: string[];
+}
+
+/**
+ * Normalize an array of items into a normalized state structure
+ * @param items Array of items with an 'id' property
+ * @returns Normalized state with byId and allIds
+ */
+export function normalize(
+ items: T[],
+): NormalizedState {
+ const byId: Record = {};
+ const allIds: string[] = [];
+
+ for (const item of items) {
+ if (item.id) {
+ byId[item.id] = item;
+ allIds.push(item.id);
+ }
+ }
+
+ return { byId, allIds };
+}
+
+/**
+ * Denormalize a normalized state back into an array
+ * @param normalized Normalized state structure
+ * @returns Array of items in the order of allIds
+ */
+export function denormalize(
+ normalized: NormalizedState,
+): T[] {
+ return normalized.allIds
+ .map((id) => normalized.byId[id])
+ .filter((item): item is T => item !== undefined);
+}
+
+/**
+ * Add an item to normalized state
+ * @param normalized Current normalized state
+ * @param item Item to add
+ * @param position Optional position to insert at (default: append)
+ * @returns New normalized state
+ */
+export function addToNormalized(
+ normalized: NormalizedState,
+ item: T,
+ position?: number,
+): NormalizedState {
+ const { byId, allIds } = normalized;
+ const newById = { ...byId, [item.id]: item };
+
+ let newAllIds: string[];
+ if (position === undefined || position >= allIds.length) {
+ // Append to end
+ newAllIds = [...allIds, item.id];
+ } else if (position <= 0) {
+ // Prepend to start
+ newAllIds = [item.id, ...allIds];
+ } else {
+ // Insert at position
+ newAllIds = [
+ ...allIds.slice(0, position),
+ item.id,
+ ...allIds.slice(position),
+ ];
+ }
+
+ return { byId: newById, allIds: newAllIds };
+}
+
+/**
+ * Update an item in normalized state
+ * @param normalized Current normalized state
+ * @param itemId ID of item to update
+ * @param updates Partial updates to apply
+ * @returns New normalized state
+ */
+export function updateInNormalized(
+ normalized: NormalizedState,
+ itemId: string,
+ updates: Partial,
+): NormalizedState {
+ const existing = normalized.byId[itemId];
+ if (!existing) {
+ return normalized;
+ }
+
+ return {
+ ...normalized,
+ byId: {
+ ...normalized.byId,
+ [itemId]: { ...existing, ...updates },
+ },
+ };
+}
+
+/**
+ * Remove an item from normalized state
+ * @param normalized Current normalized state
+ * @param itemId ID of item to remove
+ * @returns New normalized state
+ */
+export function removeFromNormalized(
+ normalized: NormalizedState,
+ itemId: string,
+): NormalizedState {
+ const { [itemId]: removed, ...byId } = normalized.byId;
+ const allIds = normalized.allIds.filter((id) => id !== itemId);
+
+ return { byId, allIds };
+}
+
+/**
+ * Replace all items in normalized state
+ * @param normalized Current normalized state
+ * @param items New array of items
+ * @returns New normalized state
+ */
+export function replaceNormalized(
+ normalized: NormalizedState,
+ items: T[],
+): NormalizedState {
+ return normalize(items);
+}
+
+/**
+ * Merge items into normalized state (add new, update existing)
+ * @param normalized Current normalized state
+ * @param items Items to merge
+ * @returns New normalized state
+ */
+export function mergeNormalized(
+ normalized: NormalizedState,
+ items: T[],
+): NormalizedState {
+ const newById = { ...normalized.byId };
+ const newAllIds = [...normalized.allIds];
+ const existingIds = new Set(normalized.allIds);
+
+ for (const item of items) {
+ if (item.id) {
+ newById[item.id] = item;
+ if (!existingIds.has(item.id)) {
+ newAllIds.push(item.id);
+ existingIds.add(item.id);
+ }
+ }
+ }
+
+ return { byId: newById, allIds: newAllIds };
+}
+
+/**
+ * Get an item by ID from normalized state
+ * @param normalized Normalized state
+ * @param itemId ID to look up
+ * @returns Item or undefined
+ */
+export function getById(
+ normalized: NormalizedState,
+ itemId: string,
+): T | undefined {
+ return normalized.byId[itemId];
+}
+
+/**
+ * Get multiple items by IDs from normalized state
+ * @param normalized Normalized state
+ * @param itemIds Array of IDs to look up
+ * @returns Array of items (may contain undefined for missing IDs)
+ */
+export function getByIds(
+ normalized: NormalizedState,
+ itemIds: string[],
+): (T | undefined)[] {
+ return itemIds.map((id) => normalized.byId[id]);
+}
+
+/**
+ * Check if an item exists in normalized state
+ * @param normalized Normalized state
+ * @param itemId ID to check
+ * @returns True if item exists
+ */
+export function hasId(
+ normalized: NormalizedState,
+ itemId: string,
+): boolean {
+ return itemId in normalized.byId;
+}
+
+/**
+ * Get the count of items in normalized state
+ * @param normalized Normalized state
+ * @returns Number of items
+ */
+export function getCount(normalized: NormalizedState): number {
+ return normalized.allIds.length;
+}
+
+/**
+ * Create an empty normalized state
+ * @returns Empty normalized state
+ */
+export function createEmptyNormalized(): NormalizedState {
+ return { byId: {}, allIds: [] };
+}
+
+/**
+ * Reorder items in normalized state
+ * @param normalized Current normalized state
+ * @param fromIndex Source index
+ * @param toIndex Destination index
+ * @returns New normalized state with reordered allIds
+ */
+export function reorderNormalized(
+ normalized: NormalizedState,
+ fromIndex: number,
+ toIndex: number,
+): NormalizedState {
+ const newAllIds = [...normalized.allIds];
+ const [removed] = newAllIds.splice(fromIndex, 1);
+ newAllIds.splice(toIndex, 0, removed);
+
+ return {
+ ...normalized,
+ allIds: newAllIds,
+ };
+}
+
diff --git a/apps/web/src/utils/storeSelectors.ts b/apps/web/src/utils/storeSelectors.ts
index 718f2511f..9eebe465b 100644
--- a/apps/web/src/utils/storeSelectors.ts
+++ b/apps/web/src/utils/storeSelectors.ts
@@ -11,6 +11,7 @@ import { useAuthStore } from '@/stores/auth';
import { useUIStore } from '@/stores/ui';
import { useLibraryStore } from '@/stores/library';
import { useChatStore } from '@/stores/chat';
+import { denormalize } from '@/utils/stateNormalization';
/**
* FE-STATE-008: Optimized selectors for AuthStore
@@ -79,12 +80,24 @@ export function useUIActions() {
/**
* FE-STATE-008: Optimized selectors for LibraryStore
+ * FE-STATE-009: Convert normalized state to arrays for compatibility
*/
export function useLibraryItems() {
- return useLibraryStore(useShallow((state) => state.items));
+ return useLibraryStore(useShallow((state) => denormalize(state.items)));
}
export function useLibraryFavorites() {
+ return useLibraryStore(useShallow((state) => denormalize(state.favorites)));
+}
+
+/**
+ * FE-STATE-009: Get normalized state directly (for advanced use cases)
+ */
+export function useLibraryItemsNormalized() {
+ return useLibraryStore(useShallow((state) => state.items));
+}
+
+export function useLibraryFavoritesNormalized() {
return useLibraryStore(useShallow((state) => state.favorites));
}