Managing Local and Cloud Data in React: A Guide to Avoiding Race Conditions
Business Problem
The app should store students’ answers every time there is a change in the value of the answers. Because this is a very dynamic exercise, for example, a user writing an essay, we are saving things locally and then saving the answers on the cloud when the user is ready to submit them. It wouldn’t be wise to call the API to save answers 1,000 times. The user might go back to a given answer, and we would need to check if there is an answer already stored locally and display it. You can read more about the initial setup and how I used useEffect effectively in the previous article I Thought I Knew useEffect, But I Was Wrong: useEffect Misconceptions .
Saving and Retrieving Answers Locally
To manage local data storage effectively, we need to implement two functions: saveAnswerLocally and getAnswerLocally. These functions will handle saving and retrieving the answers from localStorage
.
Here’s how these functions are implemented:
const useExamsStore = () => {
const saveAnswerLocally = async (answer) => {
set({ isLoading: true });
try {
const { variation, examUuid, questionUuid, studentAnswer } = answer;
if (!examUuid || !questionUuid || !studentAnswer) {
console.log("Missing fields:", { variation, examUuid, questionUuid, studentAnswer });
return;
}
let answers = JSON.parse(localStorage.getItem('answers')) || {};
answers[`${examUuid}-${questionUuid}`] = { variation, studentAnswer };
localStorage.setItem('answers', JSON.stringify(answers));
} catch (e) {
set({ error: e });
throw e;
} finally {
set({ isLoading: false });
}
};
const getAnswerLocally = ({ examUuid, questionUuid }) => {
let answers = JSON.parse(localStorage.getItem('answers')) || {};
let answer = answers[`${examUuid}-${questionUuid}`];
if (!answer) {
return null;
}
return answer;
};
return { saveAnswerLocally, getAnswerLocally };
};
Race Conditions
In theory, saving the answer would be simple, but when the user returns to review a question, there is a problem: the answer is empty. Why?
Here’s the detailed flow that led to the race condition:
- The component mounts, and the
useEffect
hook is triggered to retrieve the local answer usinggetAnswerLocally
. - Simultaneously, another useEffect hook is triggered to save the current (initially empty) answer.
- Since the retrieval and saving operations are not synchronized, the empty answer is saved before the retrieval completes, resulting in the answer appearing as empty when the user returns.
Easiest Solution
To resolve this race condition, we need to ensure that the initial retrieval of the answer completes before any save operation can happen. This can be achieved using a loaded state variable to track whether the initial load has completed.
Here’s the updated code for handling this:
import React, { useEffect, useState } from 'react';
import { Box, Typography, Checkbox } from '@material-ui/core';
function StudentsExamQuestionMultiple({ item }) {
const { saveAnswerLocally, getAnswerLocally } = useExamsStore();
const [checkedAnswers, setCheckedAnswers] = useState(
item?.answers.map(answer => ({ id: answer.id, checked: false }))
);
const [loaded, setLoaded] = useState(false);
const handleCheck = (id) => {
setCheckedAnswers(prevState =>
prevState.map(answer =>
answer.id === id ? { ...answer, checked: !answer.checked } : answer
)
);
};
// Effect to load the initial state
useEffect(() => {
const localAnswer = getAnswerLocally({ examUuid: item.examUuid, questionUuid: item.uuid });
if (localAnswer) {
setCheckedAnswers(
item?.answers.map(answer => ({
id: answer.id,
checked: localAnswer.studentAnswer.some(local => local.id === answer.id)
}))
);
}
setLoaded(true); // Mark as loaded after setting the initial state
}, [item, getAnswerLocally]);
// Effect to save the state when it changes, only if loaded is true
useEffect(() => {
if (loaded) {
const saveAnswer = () => {
saveAnswerLocally({ ...item, studentAnswer: checkedAnswers.filter(answer => answer.checked) });
};
// Save the answer when the checkedAnswers change
saveAnswer();
// Cleanup function to save the answer when the component unmounts or item changes
return saveAnswer;
}
}, [item, checkedAnswers, saveAnswerLocally, loaded]);
return (
<Box>
<Typography variant="h5">
{item?.question}
</Typography>
{item?.answers?.length > 0 && (
<Box>
<Box sx={{ paddingTop: 2 }} />
{item?.answers.map((answer, index) => (
<Box key={answer.id}
sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Checkbox color='accent' checked={checkedAnswers[index].checked}
onChange={() => handleCheck(answer.id)} />
<span>{answer.text}</span>
</Box>
</Box>
))}
</Box>
)}
</Box>
);
}
export default StudentsExamQuestionMultiple;
Why This Solution Works
- State Management: By using a loaded state variable, we ensure that the initial load completes before any save operation can occur.
- Synchronization: The retrieval and save operations are synchronized, preventing the race condition where an empty answer is saved before the initial retrieval completes.
- Reliability: This approach guarantees that the user’s progress is reliably stored and retrieved, providing a seamless user experience.
Conclusion
Handling race conditions in React applications, particularly when dealing with dynamic user data, requires careful consideration and precise implementation. By introducing a loaded state variable, we can ensure that data retrieval completes before any save operations are initiated, effectively preventing race conditions. This approach not only enhances the reliability of data persistence but also ensures a seamless user experience.
It’s crucial to note that while local storage is a convenient solution for temporary data storage, we must also be mindful of its limitations and security implications. In scenarios where multiple students might use the same computer, it’s essential to implement strategies for cleaning up local storage to prevent data leakage and ensure privacy. This can include clearing local storage when the user logs out or periodically purging old data to maintain a clean and secure environment.
By adhering to these best practices, we can build robust, user-friendly applications that handle data efficiently and securely, providing a better experience for all users.
Empower Your Tech Journey:
Explore a wealth of knowledge designed to elevate your tech projects and understanding. From safeguarding your applications to mastering serverless architecture, discover articles that resonate with your ambition.
New Projects or Consultancy
For new project collaborations or bespoke consultancy services, reach out directly and let’s transform your ideas into reality. Ready to take your project to the next level?
- Email me at one@upskyrocket.com
- Visit My Partner In Tech for custom solutions
Protecting Routes With AWS
- How to Create Protected Routes Using React, Next.js, and AWS Amplify
- How to Protect Routes for Admins in React Next.js Using HOC
- Secure Your Next.js App: Advanced User Management with AWS Cognito Groups
Mastering Serverless Series
- Mastering Serverless (Part I): Enhancing DynamoDB Interactions with Document Client
- Mastering Serverless (Part II): Mastering AWS DynamoDB Batch Write Failures for a Smoother Experience.
- Mastering Serverless (Part III): Enhancing AWS Lambda and DynamoDB Interactions with Dependency Injection
- Mastering Serverless IV: Unit Testing DynamoDB Dependency Injection With Jest
- Mastering Serverless (Part V): Advanced Logging Techniques for AWS Lambda
Advanced Serverless Techniques
- Advanced Serverless Techinques I: Do Not Repeat Yourself
- Advanced Serverless Techniques II: Streamlining Data Access with DAL
- Advanced Serverless Techniques III: Simplifying Lambda Functions with Custom DynamoDB Middleware
- Advanced Serverless Techniques IV: AWS Athena for Serverless Data Analysis
- Advanced Serverless Techniques V: DynamoDB Streams vs. SQS/SNS to Lambda