Managing Related Models with MongoDB, Express, and React

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