Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
- Tailwind CSS
- Zod
- React Transition
- Zustand
- Redux
- React Hook Form
- Custom Middleware Module

Expand Down
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,10 @@
"tailwindcss-animate": "^1.0.7",
"ufo": "^1.3.2",
"zod": "^3.22.4",
"zustand": "^4.5.2"
"redux": "^5.0.1",
"react-redux": "^9.2.0",
"@reduxjs/toolkit": "^2.10.0",
"redux-persist": "^6.0.0"
},
"devDependencies": {
"@faker-js/faker": "^8.4.1",
Expand Down
238 changes: 161 additions & 77 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

7 changes: 6 additions & 1 deletion src/components/Cart/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,12 @@
items: { length: itemsCount },

_isHydrated: isCartStoreLoaded,
} = useCartStore();
} = useCartStore(({ setItems, removeItem, items, _isHydrated }) => ({
setItems,
removeItem,
items,
_isHydrated,
}));

const { pending, makeApiCall: createOrder } = useApi(
() => {
Expand Down Expand Up @@ -83,7 +88,7 @@
}

syncCart();
}, [isCartStoreLoaded]);

Check warning on line 91 in src/components/Cart/index.tsx

View workflow job for this annotation

GitHub Actions / Run linter

React Hook useEffect has missing dependencies: 'items' and 'setItems'. Either include them or remove the dependency array

const isCartLoaded = isCartStoreLoaded && isSynchronized;

Expand Down
6 changes: 5 additions & 1 deletion src/components/CartButton/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@ export const CartButton = ({
addItem,

removeItem,
} = useCartStore();
} = useCartStore(({ addItem, removeItem, items }) => ({
addItem,
removeItem,
items,
}));

const isProductAdded = items.some(({ id }) => product.id === id);

Expand Down
4 changes: 3 additions & 1 deletion src/components/OrderIntro/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ export const OrderIntro = ({
}: OrderIntroProps) => {
const router = useRouter();

const { emptyOut: emptyOutCart } = useCartStore();
const { emptyOut: emptyOutCart } = useCartStore(({ emptyOut }) => ({
emptyOut,
}));

const {
order: { id: orderId },
Expand Down
21 changes: 8 additions & 13 deletions src/contexts/cart/Provider.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,20 @@
'use client';

import { type PropsWithChildren, useRef } from 'react';
import { useEffect } from 'react';
import { type PropsWithChildren, useRef, useEffect } from 'react';
import { Provider } from 'react-redux';

import { CartStoreApiContext } from './context';
import { createCartStore, type CartStoreApi } from './store';
import { createCartStore } from './store';

export const CartStoreApiProvider = ({ children }: PropsWithChildren) => {
const cartStoreApiRef = useRef<CartStoreApi>();
const storeRef = useRef<ReturnType<typeof createCartStore>>();

if (!cartStoreApiRef.current) {
cartStoreApiRef.current = createCartStore();
if (!storeRef.current) {
storeRef.current = createCartStore();
}

useEffect(() => {
cartStoreApiRef.current!.persist.rehydrate();
storeRef.current?.persistor.persist();
}, []);

return (
<CartStoreApiContext.Provider value={cartStoreApiRef.current}>
{children}
</CartStoreApiContext.Provider>
);
return <Provider store={storeRef.current.store}>{children}</Provider>;
};
7 changes: 0 additions & 7 deletions src/contexts/cart/context.ts

This file was deleted.

158 changes: 94 additions & 64 deletions src/contexts/cart/store.ts
Original file line number Diff line number Diff line change
@@ -1,76 +1,106 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import {
createSlice,
configureStore,
type PayloadAction,
} from '@reduxjs/toolkit';
import { persistStore, persistReducer } from 'redux-persist';
import createWebStorage from 'redux-persist/lib/storage/createWebStorage';

import type { Product } from '#server/cms/collections/types';

export interface CartStore {
const createNoopStorage = () => {
return {
getItem() {
return Promise.resolve(null);
},
setItem(_key: string, value: number) {
return Promise.resolve(value);
},
removeItem() {
return Promise.resolve();
},
};
};

const storage =
typeof window !== 'undefined'
? createWebStorage('local')
: createNoopStorage();

export interface CartState {
items: Product[];

_isHydrated: boolean;

setIsHydrated: (isHydrated: boolean) => void;

addItem: (item: Product) => void;
removeItem: (id: Product['id']) => void;

setItems: (id: Product[]) => void;

emptyOut: () => void;
}

export const createCartStore = () =>
create(
persist<CartStore>(
(set) => ({
items: [],

_isHydrated: false,

setIsHydrated: (isHydrated: boolean) => {
set({
_isHydrated: isHydrated,
});
},

addItem: (item) => {
set((state) => {
return { items: [...state.items, item] };
});
},

removeItem: (id) => {
set((state) => {
return {
items: state.items.filter((item) => item.id !== id),
};
});
},

setItems: (items: Product[]) => {
set({
items,
});
},
const cartSlice = createSlice({
name: 'cart',
initialState: (): CartState => ({
items: [],
_isHydrated: false,
}),
reducers: {
setIsHydrated: (state, action: PayloadAction<boolean>) => {
state._isHydrated = action.payload;
},
addItem: (state, action: PayloadAction<Product>) => {
state.items.push(action.payload);
},
removeItem: (state, action: PayloadAction<Product['id']>) => {
state.items = state.items.filter((item) => item.id !== action.payload);
},
setItems: (state, action: PayloadAction<Product[]>) => {
state.items = action.payload;
},
emptyOut: (state) => {
state.items = [];
},
},
});

export const { setIsHydrated, addItem, removeItem, setItems, emptyOut } =
cartSlice.actions;

export const createCartStore = () => {
const persistedReducer = persistReducer(
{
key: 'cart-storage',
version: 1,
storage,
whitelist: ['items'],
},
cartSlice.reducer,
);

emptyOut: () => {
set(() => {
return {
items: [],
};
});
},
const store = configureStore({
reducer: persistedReducer,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: false,
}),
{
version: 1,
name: 'cart-storage',
});

const persistor = persistStore(
store,
{
// @ts-expect-error - manualPersist is not in the official types but is supported
manualPersist: true,
},
() => {
store.dispatch(setIsHydrated(true));
},
);

skipHydration: true,
return {
store,
persistor,
};
};

onRehydrateStorage: () => (state) => {
state?.setIsHydrated(true);
},
},
),
);
export type CartStore = ReturnType<typeof createCartStore>['store'];
export type CartStoreApi = CartStore;
export type RootState = ReturnType<CartStore['getState']>;

export type CartStoreApi = ReturnType<typeof createCartStore>;
export const ROOT_STATE_PROP_NAMES: (keyof RootState)[] = [
'items',
'_isHydrated',
];
65 changes: 52 additions & 13 deletions src/hooks/use-cart-store.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,64 @@
'use client';

import { useContext } from 'react';
import { useStore as extractStore } from 'zustand';
import { useSelector, useDispatch, shallowEqual } from 'react-redux';
import { useMemo } from 'react';

import { CartStoreApiContext } from '@/contexts/cart/context';
import type { CartStore } from '@/contexts/cart/store';
import type { RootState, CartState } from '@/contexts/cart/store';
import type { Product } from '#server/cms/collections/types';
import {
addItem as addItemAction,
removeItem as removeItemAction,
setItems as setItemsAction,
emptyOut as emptyOutAction,
setIsHydrated as setIsHydratedAction,
} from '@/contexts/cart/store';

function useCartStore<_ = CartStore>(): CartStore;
export interface CartStore extends CartState {
addItem: (item: Product) => void;
removeItem: (id: Product['id']) => void;
setItems: (items: Product[]) => void;
emptyOut: () => void;
setIsHydrated: (isHydrated: boolean) => void;
}

function useCartStore(): CartStore;
function useCartStore<T>(selector: (store: CartStore) => T): T;
function useCartStore<T>(selector?: (store: CartStore) => T) {
const cartStoreApi = useContext(CartStoreApiContext);
function useCartStore<T>(selector?: (store: CartStore) => T): T | CartStore {
const dispatch = useDispatch();

if (!cartStoreApi) {
throw new Error(`useCartStore must be used within CartStoreApiProvider`);
}
const state = useSelector(
({ items, _isHydrated }: RootState) => ({
items,
_isHydrated,
}),
shallowEqual,
);

// Memoize actions to prevent recreating them on every render
const actions = useMemo(() => {
return {
addItem: (item: Product) => dispatch(addItemAction(item)),
removeItem: (id: Product['id']) => dispatch(removeItemAction(id)),
setItems: (items: Product[]) => dispatch(setItemsAction(items)),
emptyOut: () => dispatch(emptyOutAction()),
setIsHydrated: (isHydrated: boolean) =>
dispatch(setIsHydratedAction(isHydrated)),
};
}, [dispatch]);

// Memoize the combined store object
const store = useMemo<CartStore>(() => {
return {
...state,
...actions,
};
}, [state, actions]);

if (selector) {
return extractStore(cartStoreApi, selector);
} else {
return extractStore(cartStoreApi);
return selector(store);
}

return store;
}

export { useCartStore };
Loading