דוגמת קוד: שילוב Redux עם Next.js
שילוב בין next.js ל Redux יכול להיות מבלבל כי כל אחת משתי הספריות רוצה לעשות משהו שהספריה השנייה עושה - בדוגמאות של next הם שמחים להראות איך עובדים רק עם next ומנהלים סטייט לגמרי בתוך העולם שלהם, ובדוגמאות של רידאקס הם שמחים להראות איך לכתוב store לעמוד אחד אבל כשצריך להתמודד עם מספר עמודים וסטייט בצד שרת יותר קשה למצוא דוגמאות.
בפוסט זה אני מציג רעיון אחד לשילוב בין הספריות שמבוסס על העקרונות הבאים:
קוד צד שרת מכין אוביקט מידע (דרך שליפה מבסיס הנתונים, גישה לשירותי צד שלישי או כל מנגנון אחר).
אוביקט המידע הזה הופך ל Store של רידאקס ונשלח לצד הלקוח.
קומפוננטות שקוראות מרידאקס צריכות לבחור מה העמוד הראשי שהן מצפות להיות בו, כדי שנדע איזה State Object הן צפויות לקבל.
קוד ה Server Side Rendering יכול לעבוד עם אוביקט המידע שהשרת הכין וקוד צד לקוח יכול לעשות dispatch ל Actions לתוך אוביקט המידע הזה.
הריפו של הדוגמה זמין כאן:
https://github.com/ynonp/next-pages-redux
בואו נראה איך זה עובד.
1. קובץ ה store
תחנה ראשונה היא הקובץ store.ts. מה שחשוב כאן הוא להגדיר פונקציה בשם makeStore
שמקבלת את הסטייט הראשוני ומחזירה store שמתאימה לו. אני גם הוספתי תמיכה בפעולת set שכותבת ערך לאיזשהו שדה ב store:
import { configureStore, createReducer } from '@reduxjs/toolkit'
import { set } from './actions';
export function makeStore(initialState: any) {
return configureStore({
reducer: createReducer(initialState, builder => {
builder.addCase(set, (state, action) => {
// @ts-ignore
state[action.payload.key] = action.payload.value;
})
})
})
}
// Infer the `RootState` and `AppDispatch` types from the store itself
export type TState = ReturnType<typeof makeStore>;
export type RootState = ReturnType<TState['getState']>
// Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState}
export type AppDispatch = TState['dispatch']
2. קבצי הדפים
בפרויקט הדוגמה יש שני דפים: counter ו names. לכל דף יש אוביקט סטייט ראשי שונה, רק אחרי הפעלת makeStore
נדע מי אוביקט הסטייט שבו אנחנו משתמשים. זה הקוד של counter.ts
:
import {useSelector, TypedUseSelectorHook} from 'react-redux';
export type Counter = {
count: number;
}
export const initialState: Counter = {
count: 0,
}
export const useCounterSelector: TypedUseSelectorHook<Counter> = useSelector;
וזה הקוד של names.ts
:
import {useSelector, TypedUseSelectorHook} from 'react-redux';
import { createAction } from '@reduxjs/toolkit';
import type {SetFieldPayload} from './types';
export type Names = {
firstName: string,
lastName: string,
}
export const initialState: Names = {
firstName: 'a',
lastName: 'b',
}
export const useNamesSelector: TypedUseSelectorHook<Names> = useSelector;
נשים לב שבפרויקט הדוגמה בחרתי להשתמש ב Action יחיד שיתאים לכל הדפים, אבל בהחלט אפשר היה לדמיין שכל דף יהיה slice עם actions משלו. במצב כזה הפונקציה makeStore
היתה מקבלת Reducer במקום רק initial state.
3. הקומפוננטות
קומפוננטות שונות יכולות להיכנס לדפים שונים, כי הן פונות לנתיבים אחרים בסטייט. יותר מזה כשאני נכנס לדף מסוים רק הפריטים של דף זה בכלל זמינים בתוך הסטייט, כלומר אני לא יכול להציג קומפוננטה שפונה ל names בתוך דף ה counter ולהיפך.
בשביל לציין את זה בקוד הקומפוננטה הגדרתי פונקציית useSelector
שונה לכל דף, וכך קומפוננטת ה names משתמשת ב selector שמתאים לדף שלה, כלומר:
import React from 'react'
import { useSelector, useDispatch } from 'react-redux'
import { set } from '../redux/actions';
import Link from 'next/link'
import { useNamesSelector } from '@/redux/pages/names';
export default function() {
const dispatch = useDispatch();
const firstName = useNamesSelector(s => s.firstName);
const lastName = useNamesSelector(s => s.lastName);
function swap() {
dispatch(set({key: 'firstName', value: lastName}))
dispatch(set({key: 'lastName', value: firstName}))
}
return (
<div>
<p>{firstName}</p>
<p>{lastName}</p>
<button onClick={swap}>Swap</button>
<Link href="/">Counter</Link>
</div>
)
}
הפונקציה useNamesSelector
מוגדרת בסלייס של names ומתאימה ל State של דף השמות. טייפסקריפט מספיק חכם בשביל להבין שה State בתוך פונקציה זו הוא מסוג Names, וכך יש לי השלמה אוטומטית ובדיקת טיפוסים.
4. חיבור ל next
נשאר לנו רק לחבר את הקוד ל next. אני משתמש ב Pages Router. בקובץ _app.tsx
אני כותב:
import '@/styles/globals.css'
import type { AppProps } from 'next/app'
import { makeStore } from '../redux/store'
import { Provider } from 'react-redux'
export default function App({ Component, pageProps }: AppProps) {
const store = makeStore(pageProps);
return <Provider store={store}>
<Component />
</Provider>
}
וכך יוצר את ה store מתוך ה pageProps, שזה אוביקט המידע שהשרת הכין. בכל אחד מהדפים אני מאתחל את ה pageProps, לדוגמה דף השמות נראה כך:
import Image from 'next/image'
import Names from '@/components/Names';
export const getServerSideProps = (async () => {
return { props: {firstName: 'first', lastName: 'last'} };
});
export default function Home() {
return (
<main>
<Names />
</main>
)
}
הפונקציה getServerSideProps
מאתחלת את אוביקט המידע, נקסט יעביר אותו ל App
ושם ייווצר ה Store וכל מעבר דף ה Store יאותחל עם המידע החדש שמגיע מהשרת.