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

View File

@ -1,25 +1,19 @@
import usePolling from "../util/Polling"
const uri = '/api/games';
export default function useGamesApi(gamesReducer) {
const [games, dispatchGames] = gamesReducer;
export default function useGamesApi(gamesState) {
const [games, setGames] = gamesState;
const useList = (pollingReducer) => {
const [polling, dispatchPolling] = pollingReducer;
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
const [list, isFetching] = usePolling(uri, mode);
const isPolling = usePolling('/api/games', setGames, mode);
if (polling.games !== isFetching) {
dispatchPolling({ type: 'next', games: isFetching });
}
if (games.list !== list) {
dispatchGames({ type: 'next', list });
if (isPolling !== polling.games) {
dispatchPolling({ type: 'next', games: isPolling });
}
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(leaderboardReducer) {
const [leaderboard, dispatchLeaderboaed] = leaderboardReducer;
export default function useLeaderboardApi() {
const [leaderboard, setLeaderboard] = useState(null);
const usePoll = (pollingReducer) => {
const [polling, dispatchPolling] = pollingReducer;
@ -12,14 +11,10 @@ export default function useLeaderboardApi(leaderboardReducer) {
? { interval_sec: 300 } // update leaderbord stats every 5 min
: { interval_stop: true } // user has fliped OfflineToggel
const [table, isFetching] = usePolling(uri, mode);
const isPolling = usePolling('/api/leaderboard', setLeaderboard, mode);
if (polling.leaderboard !== isFetching) {
dispatchPolling({ type: 'next', leaderboard: isFetching });
}
if (leaderboard.table !== table) {
dispatchLeaderboaed({ type: 'next', table });
if (isPolling !== polling.leaderboard) {
dispatchPolling({ type: 'next', leaderboard: isPolling });
}
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([user, dispatchUser]) {
export default function useUserApi() {
const [user, dispatchUser] = useUserReducer();
const useGet = () => {
const [nextUser] = usePolling(uri);
if (typeof nextUser?.username === 'string' && nextUser.username !== user.username) {
dispatchUser({ type: "next", username: nextUser.username });
const onResponce = (json) => {
dispatchUser({ type: "parse", json });
}
usePolling('/api/user', onResponce); // <<-- fetch once
return user;
}

View File

@ -4,15 +4,14 @@ import Loading from '../components/Loading';
export default function Leaderboard({ leaderboard, user }) {
const table = leaderboard?.table;
if (!table)
if (leaderboard == null)
return <Loading />
const isCurrentUser = (playerName) =>
user?.isCurrentUser(playerName) === true ? true : null;
const tableRows = Object.keys(table).map(playerName => {
var rank = table[playerName];
const tableRows = Object.keys(leaderboard).map(playerName => {
var rank = leaderboard[playerName];
return <tr key={playerName} className={isCurrentUser(playerName) && 'currentuser'}>
<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 { reducer, initialState } from "./reducer"
export const AppContext = React.createContext({
export const Games = React.createContext({
state: initialState,
dispatch: () => null
})
export const AppContextProvider = ({ children }) => {
export const GamesProvider = ({ children }) => {
const [state, dispatch] = React.useReducer(reducer, initialState)
return (
<AppContext.Provider value={[ state, dispatch ]}>
<Games.Provider value={[ state, dispatch ]}>
{ children }
</AppContext.Provider>
</Games.Provider>
)
}

View File

@ -3,18 +3,11 @@ import ReactDOM from 'react-dom/client';
import './index.css';
import reportWebVitals from './reportWebVitals';
import App from './App';
import { AppDataProvider } from "./context/data"
import { AppContextProvider } from "./context/app"
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<AppContextProvider>
<AppDataProvider>
<App />
</AppDataProvider>
</AppContextProvider>
</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 { localeCompare } from '../util/Locale';
import { nextState } from '../util/StateHelper';
//import { nextState } from '../util/StateHelper';
export const userInitialState = {
username: '',
@ -13,8 +13,13 @@ export const userInitialState = {
export function userReducer(state, action) {
switch (action.type) {
case 'next':
return nextState(state, action);
case 'parse':
const apiData = parse(action.json);
return {
...state,
...apiData
};
default:
throw Error('UserReducer: unknown action.type', action.type);
@ -24,3 +29,10 @@ export function userReducer(state, action) {
export default function useUserReducer() {
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
- mode:
- null - default, return cashed value while polling fresh one from server)
- null - default, fetch data ONCE
- interval_sec
- interval_stop
*/
export default function usePolling(url, mode) {
const [cache, setCache] = useState(null);
const [isFetching, setFetching] = useState(false);
const [delayID, setDelayID] = useState(null);
export default function usePolling(uri, onResponce, mode = null) {
const [initialPoll, setInitialPoll] = useState(true);
const [isPolling, setPolling] = useState(false);
const [intervalTimer, setIntervalTimer] = useState(null);
const fetchData = useCallback(() => {
setDelayID(null);
setFetching(true);
const pollData = useCallback(() => {
setPolling(true);
setInitialPoll(false);
fetch(url)
.then((response) => response.json())
.then((freshData) => {
setCache(freshData);
setFetching(false);
fetch(uri)
.then((responce) => {
setPolling(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) => {
console.warn(err.message);
setFetching(false);
})
}, [url])
}, [uri, mode, onResponce]);
useEffect(() => {
if (cache === null && isFetching === false) {
fetchData();
if ((initialPoll || (typeof mode?.interval_sec === 'number' && intervalTimer === null)) && !isPolling) {
pollData();
}
}, [initialPoll, mode, intervalTimer, isPolling, pollData]);
if (mode?.interval_stop && intervalTimer) {
console.log("Cancel scheduled fetch for", uri);
clearTimeout(intervalTimer);
setIntervalTimer(null);
setInitialPoll(true);
}
if (mode?.interval_sec && delayID === null) {
const timeoutID = setTimeout(fetchData, mode.interval_sec * 1000)
setDelayID(timeoutID)
console.log("Fetch '" + url + "' scheduled in " + mode.interval_sec + " sec")
}
else if (mode?.interval_stop) {
clearTimeout(delayID); // cancel already scheduled fetch
setDelayID(null);
}
}, [url, mode, isFetching, cache, fetchData, delayID]);
return [
cache, // API responce
isFetching // true / false
]
return isPolling;
}