In this tutorial, we'll build a simple full-stack feature using the MERN stack (MongoDB, Express, React, Node.js) that demonstrates how to work with related models—specifically, a Post that can belong to multiple Categories.
A common challenge when working with related data in MongoDB is editing and updating references between documents. For example, when editing a Post that is related to multiple Category documents, how do we display the existing relationships, allow the user to edit them in the front-end, and then persist those changes back to the database?
We’ll break this down step-by-step:
- Creating related models with Mongoose
- Using .populate() to fetch complete referenced documents
- Building Express routes to fetch and update a post
- Creating a React form that allows selecting multiple categories
- Submitting only the necessary data to the backend
This guide focuses on clarity and simplicity—no CSS, no extra libraries—just the core logic you need to manage related data efficiently in a real-world app.
Let’s get started!
I. Mongoose Models
models/Category.js
const mongoose = require('mongoose');
const CategorySchema = new mongoose.Schema({
title: { type: String, required: true }
});
module.exports = mongoose.model('Category', CategorySchema);
models/Post.js
const mongoose = require('mongoose');
const PostSchema = new mongoose.Schema({
title: { type: String, required: true },
description: { type: String },
categories: [{ type: mongoose.Schema.Types.ObjectId, ref: 'Category' }]
});
module.exports = mongoose.model('Post', PostSchema);
Note: One post can have multiple categories.
II. Route Controllers
1. GET /api/post/:id – Fetch a post along with its associated categories
router.get('/api/posts/:id', async (req, res) => {
try {
const post = await Post.findById(req.params.id).populate('categories');
if (!post) return res.status(404).json({ message: 'Post not found' });
res.json(post);
} catch (error) {
res.status(500).json({ message: 'Server error', error });
}
});
2. POST /api/post/:id – Update a post and its category relationships
router.post('/api/posts/:id', async (req, res) => {
try {
const { title, description, categories } = req.body;
const updatedPost = await Post.findByIdAndUpdate(
req.params.id,
{
title,
description,
categories: categories ? categories.map(id => id) : []
},
{ new: true }
).populate('categories');
if (!updatedPost) return res.status(404).json({ message: 'Post not found' });
res.json(updatedPost);
} catch (error) {
res.status(500).json({ message: 'Server error', error });
}
});
Here we expect req.body.categories to be an array of category IDs from the frontend, so we sanitize it with .map(id => id).
III. The view
1. State variables
const [formData, setFormData] = useState({
title: '',
description: '',
categories: [],
});
const [allCategories, setAllCategories] = useState([]);
2. Fetching the data
useEffect(() => {
const fetchData = async () => {
const postRes = await fetch(`/api/posts/${postId}`);
const post = await postRes.json();
const categoriesRes = await fetch('/api/categories');
const categories = await categoriesRes.json();
setFormData({
title: post.title,
description: post.description,
categories: post.categories.map(c => c._id),
});
setAllCategories(categories);
};
fetchData();
}, [postId]);
3. Handling Post, Categories and the form submission
const handleInputChange = e => {
setFormData({ ...formData, [e.target.name]: e.target.value });
};
const handleCategoryToggle = (categoryId) => {
const alreadySelected = formData.categories.includes(categoryId);
const updatedCategories = alreadySelected
? formData.categories.filter(id => id !== categoryId)
: [...formData.categories, categoryId];
setFormData(prevData => ({
...prevData,
categories: updatedCategories,
}));
};
const handleSubmit = async e => {
e.preventDefault();
const payload = {
title: formData.title,
description: formData.description,
categories: formData.categories,
};
const res = await fetch(`/api/posts/${postId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const updatedPost = await res.json();
alert('Post updated!');
console.log(updatedPost);
};
4. And last but not least, the view:
return (
<form onSubmit={handleSubmit}>
<h1>Edit Post</h1>
<div>
<label>Title:</label>
<input
name="title"
value={formData.title}
onChange={handleInputChange}
/>
</div>
<div>
<label>Description:</label>
<textarea
name="description"
value={formData.description}
onChange={handleInputChange}
/>
</div>
<div>
<h3>Select Categories:</h3>
{allCategories.map(category => (
<label key={category._id}>
<input
type="checkbox"
checked={formData.categories.includes(category._id)}
onChange={() => handleCategoryToggle(category._id)}
/>
{category.title}
</label>
))}
</div>
<button type="submit">Save</button>
</form>
);
Final thoughts
In this post, we walked through:
- Creating related models with Mongoose
- Using .populate() to fetch complete referenced documents
- Building Express routes to fetch and update a post
- Creating a React form that allows selecting multiple categories
- Submitting only the necessary data to the backend