ref – https://www.youtube.com/watch?v=BNN4o4gnSF4
How to Operate
If you need to make updates to the questions and answer, make sure you delete existing object in questions collections.
Then create a new object with Postman like so:
POST localhost:8080/api/questions
This way, it updates mongo db.
When there are already data in results and you want to clear it, in your Postman:
DELETE localhost:8080/api/result
In our results reducer, we keep state for wechat id, douyin id and the user’s results. All answers from this user will will be saved in this slice.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
export const resultReducer = createSlice({ name: 'result', initialState : { douyinUserId : null, wechatUserId: null, result : [] }, reducers : { setDouyinUserId : (state, action) => { state.douyinUserId = action.payload }, setWechatUserId : (state, action) => { state.wechatUserId = action.payload }, ... ... }) |
Users and checking for existing users
First we check to see if the user’s wechat id or telephone exists.
When we start the app, we first save the user’s id from input to slice:
src/components/Main.js
1 2 3 4 5 6 7 |
const res = await checkIfUserExistInMongoDB(wechatId, mobile); if (Array.isArray(res) && res.length > 0) { const { mobile: respMobile, wechatUsername } = res && res.length && res[0]; if (respMobile || wechatUsername) { setMessage(ID_USED); } } |
We do a fetch using checkIfUserExistInMongoDB. It will return the user’s information if EITHER the wechat id or mobile matches. If it does, we throw out an error message.
If BOTH wechat id and mobile does not exist, we can proceed to save the data locally to the store:
src/components/Main.js
1 2 3 4 5 6 7 |
if(mobileInputRef.current?.value){ dispatch(setMobile(mobile)) } if (wechatInputRef.current?.value) { dispatch(setWechatUserId(wechatId)) } |
In startQuiz function, we dispatch actions generated from result_reducer.
src/redux/question_reducer.js
1 |
export const { setWechatUserId, setDouyinUserId, pushResultAction, resetResultAction, updateResultAction } = resultReducer.actions; |
we throw in the user entered string into the action.
src/components/Main.js
1 |
dispatch(setDouyinUserId(douyinInputRef.current?.value)) |
The action object gets into the reducer.
src/redux/result_reducer.js
1 2 3 4 5 6 7 8 9 10 |
reducers : { setDouyinUserId : (state, action) => { state.douyinUserId = action.payload }, setWechatUserId : (state, action) => { state.wechatUserId = action.payload }, ... ... } |
Question Reducer
Question reducer keeps track of what question index we’re on, the questions themselves, and the answers.
state:
queue is to represent the array of questions.
answers is to represent the array of answers the user inputs
questionIndex is to represent the cur of the data structure.
src/redux/question_reducer.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
export const questionReducer = createSlice({ name: 'questions', initialState : { queue: [], answers : [], questionIndex : 0 }, moveNextAction : (state) => { return { ...state, questionIndex : state.questionIndex + 1 } }, movePrevAction : (state) => { return { ...state, questionIndex : state.questionIndex - 1 } }, ... ... ... |
When the next button is clicked, we dispatch an action to move the question index forward.
When the prev button is clicked, we dispatch an action to move backwards.
This is very similar to how a custom array would work. We have the array data, but keep a cur to keep track of what index we’re on.
src/hooks/FetchQuestion.js
1 2 3 4 5 6 7 |
export const MoveNextQuestion = () => async (dispatch) => { try { dispatch(Action.moveNextAction()); /** increase trace by 1 */ } catch (error) { console.log(error) } } |
Okay, so this is all happening in the store.
But for the UI, before we even touch the store, we need to create local state in order to get the user selected radio index. So we define value for the radio button index that the user has checked:
src/components/Quiz.js
1 |
const [checkedIndex, setCheckedIndex] = useState(undefined) |
We then get the current question by getting the questionIndex and using it to get the element in queue.
1 2 3 4 5 |
const aQuestion = useSelector(state => { const questionIndex = state.questions.questionIndex; const questionsArr = state.questions.queue; return questionsArr[questionIndex]; }); |
Now we render the options associated with this question object.
As you know aQuestion is an object from the store.
It contains:
- question
- id – the id of the whole question
- options – an array of answer values. Thus, we use map to iterate through all the answer available.
src/components/Questions.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
{<ul key={aQuestion?.id}> { aQuestion?.options.map((answer, index) => ( <li key={index}> <input type="radio" value={false} name="options" id={`q${index}-option`} onChange={() => onSelect(index)} /> <label className='text-primary' htmlFor={`q${index}-option`}>{answer}</label> <div className={`check ${result[questionIndex] == index ? 'checked' : ''}`}></div> </li> )) } </ul>} |
Fetching that Question object
So we see how we retrieve the question object from the store. But how did we fetch from the web?
We use a custom hook called useFetchQestion.
src/components/Questions.js
1 |
const [{ isLoading, apiData, serverError}] = useFetchQestion(); |
It runs whenever our Question component is refreshed.
We start off basics. We use local state to show:
1 |
const [getData, setGetData] = useState({ isLoading : false, apiData : [], serverError: null}); |
- isLoading – whether something is loading/done
- apiData – data container from the response object
- serverError – will have data when/if error occurs
We initialize local data to default. As long as the component does not update, we use the same dispatch reference.
Note:
Its safe to add dispatch to the dependency array because its identity is stable across renders.
The doc says like in React’s useReducer, the returned dispatch function identity is stable and won’t change on re-renders
unless you change the store being passed to the , which would be extremely unusual
In similar situation, the dispatch function reference will be stable as long as the same store instance is being passed to the
However, the React hooks lint rules do not know that dispatch should be stable, and will warn that the dispatch variable should be added to dependency arrays for useEffect and useCallback. The simplest solution is to do just that:
1 2 3 4 5 6 7 8 |
export const Todos = () => { const dispatch = useDispatch() useEffect(() => { dispatch(fetchTodos()) // Safe to add dispatch to the dependencies array }, [dispatch]) } |
Using and declaring async IIFE function in the effect to fetch data
In useEffect, we must give the declaration and immediately invoke it in order for the async operation to be part of the effect. Thus that’s why we declare the function, and then execute it.
If there is an error, we set local data isLoading to false so any kind of spinner can stop.
We also pass the error object to serverError.
1 2 3 4 5 6 7 8 |
(async () => { try { dispatch(Action); } catch (error) { setGetData(prev => ({...prev, isLoading : false})); setGetData(prev => ({...prev, serverError : error})); } })(); |
Successfully fetching the data
1 |
const [{ questions, answers }] = await getServerData(`${process.env.REACT_APP_SERVER_HOSTNAME}/api/questions`, (data) => data) |
Where getServerData awaits on an axios’s get on url http://localhost:8080/api/questions
src/helper/helper.js
1 2 3 4 |
export async function getServerData(url, callback){ const data = await (await axios.get(url))?.data; return callback ? callback(data) : data; } |
ref – http://chineseruleof8.com/code/index.php/2022/12/16/axios-and-fetch-both-returns-a-promise-object/
So we see two awaits. First, we return a promise when the headers arrive. This is to let us know that the get response has returned. But in order to the get json data response, we’ll have to call json() to get another Promise object, which resolves to our data. Hence this is why we need a 2nd await.
After data returns, we create an array holder for it.
If questions array is valid, we first set local isLoading to false to say we’re done fetching data.
1 2 3 |
if(questions.length > 0) { setGetData(prev => ({...prev, isLoading : false})); setGetData(prev => ({...prev, apiData : questions})); |
Then, we apply questions to local state apiData. Once we have have apiData, getData will have the valid data to be used and we return it from the functional component. However, in our app we don’t use this apiData data. We would fetch from the store as will be explained later.
src/components/Questions.js
1 |
const [{ isLoading, apiData, serverError}] = useFetchQestion(); |
After we dispatch action startExamAction, it goes to our question reducer startExamAction and the data is loaded from the action object’s payload.
We then assign them to the slice’s state:
src/redux/question_reducer.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
export const questionReducer = createSlice({ name: 'questions', initialState : { queue: [], answers : [], questionIndex : 0 }, reducers : { startExamAction : (state, action) => { let { question, answers} = action.payload return { ...state, queue : question, answers } }, |
Pushing Next button
When the next button is pushed, it will evaluate what question index we’re on from the state.questions via question index.
If it’s still within the index of the last question, we will update the state to the next question.
src/components/Quiz.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
const { queue, questionIndex } = useSelector(state => state.questions); const result = useSelector(state => state.result.result); ... ... function onNext() { if(questionIndex < queue.length){ // increase the trace value by one using MoveNextAction dispatch(MoveNextQuestion()); // insert a new result in the array if(result.length <= questionIndex) { dispatch(PushAnswer(checkedIndex)) } } setCheckedIndex(undefined) } |
dispatch(MoveNextQuestion()) will increase the questionIndex:
src/redux/question_reducer.js
1 2 3 4 5 6 |
moveNextAction : (state) => { return { ...state, questionIndex : state.questionIndex + 1 } }, |
Once questionIndex is updated, then when we retrieve a question object from the store, it will update:
src/components/Questions.js
1 2 3 4 5 |
const aQuestion = useSelector(state => { const questionIndex = state.questions.questionIndex; // updated ! const questionsArr = state.questions.queue; // queue keeps array of question objects. return questionsArr[questionIndex]; }); |
We know that questionIndex gets updated from pressing Next button, how does it get rendered?
How does Question data get rendered?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
// data fetching and retrieving const [{ isLoading, apiData, serverError}] = useFetchQestion(); // fetch data and place into store const aQuestion = useSelector(state => { // fetches question object from store }); // question index logic const [checked, setChecked] = useState(undefined) const { questionIndex } = useSelector(state => state.questions); useEffect(() => { dispatch(updateResult({ questionIndex, checked})) }, [checked]) function onSelect(i) { ... setChecked(i) // updateResult dispatch(updateResult({ questionIndex, checked})) } |
Keeping track of the question index
questionIndex is increased/decreased via MoveNextQuestion.
1 2 3 4 5 6 7 8 9 10 |
const { queue, questionIndex } = useSelector(state => state.questions); ... ... function onNext() { if(questionIndex < queue.length){ } } |