Managing Local and Cloud Data in React: A Guide to Avoiding Race Conditions

The SaaS Enthusiast
5 min readMay 22, 2024

--

Academia Connects: Screenshot of an application that is an example of a race condition and how it was solved

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:

  1. The component mounts, and the useEffect hook is triggered to retrieve the local answer using getAnswerLocally.
  2. Simultaneously, another useEffect hook is triggered to save the current (initially empty) answer.
  3. 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?

Protecting Routes With AWS

Mastering Serverless Series

Advanced Serverless Techniques

--

--