react: state -> reducer -> context

- Leaderboard: useState
- User: useReducer
- Games: useContext [...in progress]
- usePolling giveup on internal cache
  in favour of onResponce() callback
This commit is contained in:
djmil 2023-11-09 12:29:47 +01:00
parent 7314b8c328
commit 3f47654cf2
14 changed files with 96 additions and 322 deletions

View File

@ -9,40 +9,36 @@ import About from "./components/About"
import Games from './container/Games'; import Games from './container/Games';
import Leaderboard from './container/Leaderboard'; import Leaderboard from './container/Leaderboard';
import useUserReducer from './reducer/user';
import usePollingReducer from './reducer/polling'; import usePollingReducer from './reducer/polling';
import useLeaderboardReducer from './reducer/leaderboard'; //import useGamesReducer from './reducer/games';
import useGamesReducer from './reducer/games';
import useUserApi from './api/user'; import useUserApi from './api/user';
import useLeaderboardApi from './api/leaderboard'; import useLeaderboardApi from './api/leaderboard';
import useGamesApi from './api/games'; //import useGamesApi from './api/games';
export default function App() { export default function App() {
const userReducer = useUserReducer(); const pollingReducer = usePollingReducer();
const pollingFlux = usePollingReducer(); //const gamesReducer = useGamesReducer();
const leaderboardReducer = useLeaderboardReducer();
const gamesReducer = useGamesReducer();
const user = useUserApi(userReducer).get(); //const games = useGamesApi(gamesReducer).list(pollingReducer);
const leaderboard = useLeaderboardApi(leaderboardReducer).poll(pollingFlux); const leaderboard = useLeaderboardApi().poll(pollingReducer);
/*const gamesApi = */ useGamesApi(gamesReducer).list(pollingFlux); const user = useUserApi().get();
return ( return (
<BrowserRouter> <BrowserRouter>
<Header pollingFlux={pollingFlux} /> <Header pollingReducer={pollingReducer} />
<Routes> <Routes>
<Route path="/" element={<About />} /> <Route path='/' element={<About />} />
<Route path="/about" element={<About />} /> <Route path='/about' element={<About />} />
<Route path="/games/*" element={<Games />} /> <Route path='/games/*' element={<Games />} />
<Route path="/leaderboard" element={<Leaderboard leaderboard={leaderboard} user={user} />} /> <Route path='/leaderboard' element={<Leaderboard leaderboard={leaderboard} user={user} />} />
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
) )
} }
function Header({ pollingFlux }) { function Header({ pollingReducer }) {
const [polling, dispatchPolling] = pollingFlux; const [polling, dispatchPolling] = pollingReducer;
return ( return (
<div className='Header'> <div className='Header'>
@ -52,20 +48,20 @@ function Header({ pollingFlux }) {
<OnlineToggle <OnlineToggle
isOnline={polling.enabled} isOnline={polling.enabled}
onClick={() => dispatchPolling({ type: "toggleOnOff" })} onClick={() => dispatchPolling({ type: 'toggleOnOff' })}
/> />
<nav> <nav>
<NavLink to="/about"> <NavLink to='/about'>
About About
</NavLink> </NavLink>
<NavLink to="/games"> <NavLink to='/games'>
<Wobler text="Games" dance={polling.games} /> <Wobler text="Games" dance={polling.games} />
</NavLink> </NavLink>
<NavLink to="/leaderboard"> <NavLink to='/leaderboard'>
<Wobler text="Leaderboard" dance={polling.leaderboard} /> <Wobler text='Leaderboard' dance={polling.leaderboard} />
</NavLink> </NavLink>
</nav> </nav>
</div> </div>

View File

@ -1,25 +1,19 @@
import usePolling from "../util/Polling" import usePolling from "../util/Polling"
const uri = '/api/games'; export default function useGamesApi(gamesState) {
const [games, setGames] = gamesState;
export default function useGamesApi(gamesReducer) {
const [games, dispatchGames] = gamesReducer;
const useList = (pollingReducer) => { const useList = (pollingReducer) => {
const [polling, dispatchPolling] = pollingReducer; const [polling, dispatchPolling] = pollingReducer;
const mode = (polling.enabled === true) const mode = (polling.enabled === true)
? { interval_sec: 30 } // update games list half a minue ? { interval_sec: 30 } // update games list every half a minue
: { interval_stop: true } // user has fliped OfflineToggel : { interval_stop: true } // user has fliped OfflineToggel
const [list, isFetching] = usePolling(uri, mode); const isPolling = usePolling('/api/games', setGames, mode);
if (polling.games !== isFetching) { if (isPolling !== polling.games) {
dispatchPolling({ type: 'next', games: isFetching }); dispatchPolling({ type: 'next', games: isPolling });
}
if (games.list !== list) {
dispatchGames({ type: 'next', list });
} }
return games; return games;

View File

@ -1,9 +1,8 @@
import usePolling from "../util/Polling" import { useState } from "react";
import usePolling from "../util/Polling";
const uri = '/api/leaderboard'; export default function useLeaderboardApi() {
const [leaderboard, setLeaderboard] = useState(null);
export default function useLeaderboardApi(leaderboardReducer) {
const [leaderboard, dispatchLeaderboaed] = leaderboardReducer;
const usePoll = (pollingReducer) => { const usePoll = (pollingReducer) => {
const [polling, dispatchPolling] = pollingReducer; const [polling, dispatchPolling] = pollingReducer;
@ -12,14 +11,10 @@ export default function useLeaderboardApi(leaderboardReducer) {
? { interval_sec: 300 } // update leaderbord stats every 5 min ? { interval_sec: 300 } // update leaderbord stats every 5 min
: { interval_stop: true } // user has fliped OfflineToggel : { interval_stop: true } // user has fliped OfflineToggel
const [table, isFetching] = usePolling(uri, mode); const isPolling = usePolling('/api/leaderboard', setLeaderboard, mode);
if (polling.leaderboard !== isFetching) { if (isPolling !== polling.leaderboard) {
dispatchPolling({ type: 'next', leaderboard: isFetching }); dispatchPolling({ type: 'next', leaderboard: isPolling });
}
if (leaderboard.table !== table) {
dispatchLeaderboaed({ type: 'next', table });
} }
return leaderboard; return leaderboard;

View File

@ -1,16 +1,15 @@
import usePolling from "../util/Polling" import usePolling from "../util/Polling";
import useUserReducer from "../reducer/user";
const uri = '/api/user'; export default function useUserApi() {
const [user, dispatchUser] = useUserReducer();
export default function useUserApi([user, dispatchUser]) {
const useGet = () => { const useGet = () => {
const [nextUser] = usePolling(uri); const onResponce = (json) => {
dispatchUser({ type: "parse", json });
if (typeof nextUser?.username === 'string' && nextUser.username !== user.username) {
dispatchUser({ type: "next", username: nextUser.username });
} }
usePolling('/api/user', onResponce); // <<-- fetch once
return user; return user;
} }

View File

@ -4,15 +4,14 @@ import Loading from '../components/Loading';
export default function Leaderboard({ leaderboard, user }) { export default function Leaderboard({ leaderboard, user }) {
const table = leaderboard?.table; if (leaderboard == null)
if (!table)
return <Loading /> return <Loading />
const isCurrentUser = (playerName) => const isCurrentUser = (playerName) =>
user?.isCurrentUser(playerName) === true ? true : null; user?.isCurrentUser(playerName) === true ? true : null;
const tableRows = Object.keys(table).map(playerName => { const tableRows = Object.keys(leaderboard).map(playerName => {
var rank = table[playerName]; var rank = leaderboard[playerName];
return <tr key={playerName} className={isCurrentUser(playerName) && 'currentuser'}> return <tr key={playerName} className={isCurrentUser(playerName) && 'currentuser'}>
<td>{playerName}</td> <td>{playerName}</td>

View File

@ -1,84 +0,0 @@
export const reducer = (state, action) => {
switch (action.update) {
case "game-selector":
return GameSelector_update(state, action)
case "newGame":
return updateNewGame(state, action)
default:
console.warn("Unknown action.component", action.component)
return state
}
}
export const initialState = {
gameSelector: {
selectedGameProposal: null,
selectedActiveGame: null,
selectedArchiveGame: null,
},
newGame: {
whitePlayer: '',
blackPlayer: '',
message: '',
fetching: false,
},
}
function GameSelector_update(state, action) {
if (Object.hasOwn(action, 'selectedGameProposal')) {
return {
...state,
gameSelector: {
...state.gameSelector,
selectedGameProposal: action.selectedGameProposal
}
}
}
if (Object.hasOwn(action, 'selectedActiveGame')) {
return {
...state,
gameSelector: {
...state.gameSelector,
selectedActiveGame: action.selectedActiveGame
}
}
}
if (Object.hasOwn(action, 'selectedArchiveGame')) {
return {
...state,
gameSelector: {
...state.gameSelector,
selectedArchiveGame: action.selectedArchiveGame
}
}
}
console.warn(action.component, "- bad property")
}
function updateNewGame(state, action) {
const newGame = {...state.newGame}
Object.keys(action)
.slice(1) // skip 'update' property
.forEach(actionKey => {
if (Object.hasOwn(newGame, actionKey)) {
newGame[actionKey] = action[actionKey]
} else {
console.warn("NewGame update: bad action property\n", actionKey + ":", action[actionKey])
}
})
return {
...state,
newGame
}
}

View File

@ -1,46 +0,0 @@
import { useState, useCallback, useEffect, } from "react"
/*
TODO: Poll(uri, flavour)
- uri: string
- execution_flvour:
- once (i.e. now)
- interval (sec)
- stop
*/
export default function Poll(url, interval_sec, offlineMode) {
const [dataCache, setDataCache] = useState(null)
const [fetching , setFetching ] = useState(false)
const [timeoutID, setTimeoutID] = useState(null)
const fecthData = useCallback(() => {
setTimeoutID(null)
setFetching(true)
fetch(url)
.then((response) => {
setFetching(false)
return response.json()
})
.then((freshData) => setDataCache(freshData))
.catch((err) => console.log(err.message))
}, [url])
useEffect(() => {
if (dataCache == null) {
fecthData() // <<-- run immediatly on startup
}
else if (offlineMode === true) {
clearTimeout(timeoutID) // cancel already scheduled fetch
setTimeoutID(null) // & stop interval fetching
}
else if (timeoutID === null && typeof interval_sec === 'number') {
const timeoutID = setTimeout(fecthData, interval_sec * 1000)
setTimeoutID(timeoutID)
console.log("Fetch '" +url +"' scheduled in " +interval_sec +" sec")
}
}, [url, dataCache, fecthData, timeoutID, offlineMode, interval_sec]);
return [ dataCache, fetching ]
}

View File

@ -1,40 +0,0 @@
import React from "react"
import { reducer, initialState } from "./reducer"
import Poll from "./Poll"
export const AppData = React.createContext({
state: initialState,
dispatch: () => null
})
export const AppDataProvider = ({ children }) => {
const [data, dispatchData] = React.useReducer(reducer, initialState)
const [games, gamesFetching ] = Poll('/api/gamestate' , 30, data.offlineMode)
const [leaderboard, leaderboardFetching ] = Poll('/api/leaderboard', 60, data.offlineMode)
const [user] = Poll('/api/user') // once
data.games = games
data.gamesFetching = gamesFetching
data.leaderboard = leaderboard
data.leaderboardFetching = leaderboardFetching
data.isCurrentUser = (otherUsername) => {
return user?.username && ciEquals(user.username, otherUsername) ? true : null
}
return (
<AppData.Provider value={[data, dispatchData]}>
{children}
</AppData.Provider>
)
}
function ciEquals(a, b) {
return typeof a === 'string' && typeof b === 'string'
? a.localeCompare(b, undefined, { sensitivity: 'accent' }) === 0
: a === b;
}

View File

@ -1,25 +0,0 @@
export const reducer = (state, action) => {
switch (action.type) {
case "toggleOfflineMode":
return { ...state,
offlineMode: !state.offlineMode // on/off
}
default:
console.warn("Unknown action.type", action)
return state
}
}
export const initialState = {
games: null,
gamesFetching: false,
leaderboard: null,
leaderboardFetching: false,
isCurrentUser: () => null,
offlineMode: false
}

View File

@ -1,17 +1,17 @@
import React from "react" import React from "react"
import { reducer, initialState } from "./reducer" import { reducer, initialState } from "./reducer"
export const AppContext = React.createContext({ export const Games = React.createContext({
state: initialState, state: initialState,
dispatch: () => null dispatch: () => null
}) })
export const AppContextProvider = ({ children }) => { export const GamesProvider = ({ children }) => {
const [state, dispatch] = React.useReducer(reducer, initialState) const [state, dispatch] = React.useReducer(reducer, initialState)
return ( return (
<AppContext.Provider value={[ state, dispatch ]}> <Games.Provider value={[ state, dispatch ]}>
{ children } { children }
</AppContext.Provider> </Games.Provider>
) )
} }

View File

@ -3,18 +3,11 @@ import ReactDOM from 'react-dom/client';
import './index.css'; import './index.css';
import reportWebVitals from './reportWebVitals'; import reportWebVitals from './reportWebVitals';
import App from './App'; import App from './App';
import { AppDataProvider } from "./context/data"
import { AppContextProvider } from "./context/app"
const root = ReactDOM.createRoot(document.getElementById('root')); const root = ReactDOM.createRoot(document.getElementById('root'));
root.render( root.render(
<React.StrictMode> <React.StrictMode>
<AppContextProvider> <App />
<AppDataProvider>
<App />
</AppDataProvider>
</AppContextProvider>
</React.StrictMode> </React.StrictMode>
); );

View File

@ -1,21 +0,0 @@
import { useReducer } from 'react';
import { nextState } from '../util/StateHelper';
export const leaderboardInitialState = {
table: null,
};
export function leaderboardReducer(state, action) {
switch (action.type) {
case 'next':
return nextState(state, action);
default:
throw Error('LeaderboardReducer: Unknown action.type', action.type);
}
}
export default function useLeaderboardReducer() {
return useReducer(leaderboardReducer, leaderboardInitialState);
}

View File

@ -1,6 +1,6 @@
import { useReducer } from 'react'; import { useReducer } from 'react';
import { localeCompare } from '../util/Locale'; import { localeCompare } from '../util/Locale';
import { nextState } from '../util/StateHelper'; //import { nextState } from '../util/StateHelper';
export const userInitialState = { export const userInitialState = {
username: '', username: '',
@ -13,8 +13,13 @@ export const userInitialState = {
export function userReducer(state, action) { export function userReducer(state, action) {
switch (action.type) { switch (action.type) {
case 'next': case 'parse':
return nextState(state, action); const apiData = parse(action.json);
return {
...state,
...apiData
};
default: default:
throw Error('UserReducer: unknown action.type', action.type); throw Error('UserReducer: unknown action.type', action.type);
@ -24,3 +29,10 @@ export function userReducer(state, action) {
export default function useUserReducer() { export default function useUserReducer() {
return useReducer(userReducer, userInitialState); return useReducer(userReducer, userInitialState);
} }
function parse(json) {
console.log("userreducer.parse", json);
return {
username: json.username
}
}

View File

@ -3,51 +3,53 @@ import { useState, useCallback, useEffect, } from "react"
/* /*
- uri: string - uri: string
- mode: - mode:
- null - default, return cashed value while polling fresh one from server) - null - default, fetch data ONCE
- interval_sec - interval_sec
- interval_stop - interval_stop
*/ */
export default function usePolling(url, mode) { export default function usePolling(uri, onResponce, mode = null) {
const [cache, setCache] = useState(null); const [initialPoll, setInitialPoll] = useState(true);
const [isFetching, setFetching] = useState(false); const [isPolling, setPolling] = useState(false);
const [delayID, setDelayID] = useState(null); const [intervalTimer, setIntervalTimer] = useState(null);
const fetchData = useCallback(() => { const pollData = useCallback(() => {
setDelayID(null); setPolling(true);
setFetching(true); setInitialPoll(false);
fetch(url) fetch(uri)
.then((response) => response.json()) .then((responce) => {
.then((freshData) => { setPolling(false);
setCache(freshData);
setFetching(false); if (typeof mode?.interval_sec === 'number') {
console.log("Schedule", uri, "fetch in", mode.interval_sec, "sec");
const intervalTimer = setTimeout(pollData, mode.interval_sec * 1000);
setIntervalTimer(intervalTimer);
}
return responce.json();
})
.then((json) => {
onResponce(json);
}) })
.catch((err) => { .catch((err) => {
console.warn(err.message); console.warn(err.message);
setFetching(false);
}) })
}, [url]) }, [uri, mode, onResponce]);
useEffect(() => { useEffect(() => {
if (cache === null && isFetching === false) { if ((initialPoll || (typeof mode?.interval_sec === 'number' && intervalTimer === null)) && !isPolling) {
fetchData(); pollData();
} }
}, [initialPoll, mode, intervalTimer, isPolling, pollData]);
if (mode?.interval_sec && delayID === null) { if (mode?.interval_stop && intervalTimer) {
const timeoutID = setTimeout(fetchData, mode.interval_sec * 1000) console.log("Cancel scheduled fetch for", uri);
setDelayID(timeoutID) clearTimeout(intervalTimer);
console.log("Fetch '" + url + "' scheduled in " + mode.interval_sec + " sec") setIntervalTimer(null);
} setInitialPoll(true);
else if (mode?.interval_stop) { }
clearTimeout(delayID); // cancel already scheduled fetch
setDelayID(null);
}
}, [url, mode, isFetching, cache, fetchData, delayID]);
return [ return isPolling;
cache, // API responce
isFetching // true / false
]
} }