Sep 30, 2020

Many-to-Many relations in AWS Amplify using React

Mutating and querying many-to-many relations in AWS Amplify using React.

Author

Chhavi PuriChhavi PuriSenior Software Engineer - III
Many-to-Many relations in AWS Amplify using React

In this blog, we will learn how to mutate and query many-to-many relations in AWS Amplify using React.

Prerequisites:

Before moving ahead, I highly recommend you to go through the official docs of AWS Amplify to get a know-how and be in a better position to understand the process that follows below.

Let’s understand the scenario with the help of an example:

In a university, there are many clubs and each club has many students. A student can also be a part of more than one club. So, we have an M: M (many-to-many) relationship.

There are many techniques to understand and decode this relationship. Here, we will do this with the help of an auxiliary table.

This is also shown in the data model below:

Suppose we have 2 clubs, Technovation and Codechef which have 20 students and 15 students respectively. Out of these students, there may be many who are a part of both clubs.

Before getting started with mutations and queries in this example, let’s brush up on the technologies used and a few common terms and directives that we are going to use.

1. Technologies:

GraphQL: GraphQL is a query language that is used to create, modify, and fetch data from the data sources. With GraphQL, the client gets exact data that is queried, nothing more, and nothing less. (That’s one of the main reasons behind using GraphQL over REST).

DynamoDB: We will use DynamoDB as our Data Source. Data sources are basically resources with which APIs interact. DynamoDB stores data in key-value pairs or as documents. It is a NoSQL database and has single-digit millisecond latency.

GraphQL Schema: GraphQL APIs are defined by the schema. The schema defines the data that will flow through our API and how operations (query, mutation, and subscription) will be performed on our data sources. The syntax followed by Schema is SDL(Schema Definition Language). As soon as the schema is changed, run the command amplify push

  • On running the amplify push command, GraphQL schema is converted into a set of AWS CloudFormation templates that are uploaded in the cloud. These changes can be found at amplify/backend/api/YOUR-API-NAME/build.
  •  If you change any category(API, Auth, Storage)and run the amplify push command,  AWS CloudFormation API is called to make changes in the cloud and based on those changes, aws-exports.js updates.

2. Directives

  • @model: This directive creates a table in the DynamoDB and automatically creates resolvers (create, read, update, delete, list, get, onDelete, onUpdate, onCreate) which can be configured through queries, mutations, and subscription, found under src/graphql.
  • @key: This directive allows us to create additional data access patterns. For example: If we have a table of Clubs which has a field “type” (supposing a club can be technical or non-technical) then the @key directive can help us to fetch clubs based on its “type”(technical or non-technical). 

To sort or fetch data using @key:

The fields argument can have any number of values. The first entry in the list will always be a Hash Key. If there are two entries, then the second field is sort Key and if there are more than two entries, then a single composite sort key is created for all fields, which can be found in the table as secondEntry#thirdEntry. Remember, the first entry is always a hash key.

By using the @key directive, any data can be accessed with just a single line code.

//schema
type Club @model
 @key (name:"SortbyType", fields:["type"], queryField: "SortByType"
{
  id: ID!
  clubName: String!
  type: String!
}
//appsync
query sort {
  SortByType(type: "Technical") {
    items {
      id
      clubName
    }
  }
}
//code
async function searchClubsbyType(){
  await API.graphql(graphqlOperation(sortByType,{type:"Technical"}))
   .then((data)=>{
           console.log(data)
      	 }
 ).catch((err)=>console.log(err))}
  • @connection: This directive helps to specify relationships between different tables. This directive supports one-to-one, one-to-many, and many-to-one relationships. For many-to-many relationships, we need to use two one-to-many relationships @connection and a @model

3.1 GraphQL Schema-

Club- This table contains information about our clubs(name, ID, type). 

Student- This table contains information about our students(name and ID).

StudentClubJoin- This table contains information about our connections and stores the reference of connected tables.

We will establish a one-to-many relationship between the Club table and StudentClubJoin table. Similarly, between the Student table and StudentClubJoin table and then connect both tables through StudentClubJoin.

//schema.graphql
type Club @model
@key (name:"SortbyType", fields:["type"], queryField: "SortByType"){
  id: ID!
  clubName: String!
  type: String!
  student:[StudentClubJoin]!@connection(name: "clubTableJoin")

 }
type Student @model{
  id: ID!
  studentName: String!
  club: [StudentClubJoin]! @connection(name: "studentTableJoin")
}
type StudentClubJoin @model{
  id: ID!
  club: Club!   @connection(name: "clubTableJoin")      
  student: Student! @connection(name: "studentTableJoin")
}

Note: Fields marked with “!” are mandatory.

On running the amplify push command, the @model directive will create DynamoDB tables and resolvers.

@connection directive will help connect the Club table with the StudentClubJoin table by creating the index StudentClubJoinClubId in the StudentClubJoin table. Similarly, the Student table is connected to the StudentClubJoin table by the StudentClubJoinStudentId index.

This is the older way of connecting tables where indices are created by @connection directive. 

The recommended way is to use keys where the index structure is created by @key directive and connection resolvers by @connections. So, let’s now design a schema using two 1-M @connections, a @key and a joining @model.

The recommended way is as follows: 

//schema.graphql
type Club @model
@key (name:"SortbyType", fields:["type"], queryField: "SortByType"){
  id: ID!
  clubName: String!
  type: String!
  student:[StudentClubJoin]!@connection(keyName: "byClub", fields: ["id"])
 }
type Student @model{
  id: ID!
  studentName: String!
  club: [StudentClubJoin]!@connection(keyName: "byStudent", fields: ["id"])
}
type StudentClubJoin @model
@key(name: "byClub", fields: ["clubID", "studentID"])
@key(name: "byStudent", fields: ["studentID", "clubID"]){
  id: ID!
  clubID: ID!
  studentID: ID!
  club: Club!   @connection(fields: ["clubID"])      
  student: Student!  @connection(fields: ["studentID"])
}

When we run amplify push, @key directive will create a clubID index for the club table and studentID index for the student table as a reference in the StudentClubJoin table. 

In the @connection directive, the fields argument is provided to indicate the fields that will be used to get connected objects. The fields argument can only have ID type data because we will use this data to query connected objects.

The keyName argument (in @connection directive) and the name argument in @key directive should have the same value.

Above is a screenshot of the Club table, Student table, and StudentClubJoin table (using keys). As you can see, the ID of the Club entry is stored in the StudentClubJoin table as clubID and that of the student is stored as studentID.

Without keys (schema-1), the indexes will be StudentClubJoinClubId and StudentClubJoinStudentId respectively.

3.2 Mutations-

Since our tables are connected, we will now create a club and save its ID. Similarly, we will create a student and save its ID. Then, we need to pass both the IDs to our connecting table i.e. StudentClubJoined table.

//AppSync
mutation CreateClub {
  createClub(input: {clubName: "Technovation", id: "custom-id-01", type: "Technical"}) {
    clubName
  }
}

mutation CreateStudent {
  createStudent(input: {id: "custom-id-02", studentName: "David"}) {
    studentName
  }
}

mutation JoinTables {
  createStudentClubJoin(input: {clubID: "custom-id-01", studentID: "custom-id-02"}) {
    id
  }
}

Note: If you want to use the first schema, then change clubID to StudentClubJoinClubId and studentID to StudentClubJoinStudentId everywhere (except for schema because @connections directive has already done that for you).

//code
async function createStudents(){
  const clubInput={
    clubName: "Codechef",
    type: "Technical",
    id: "Club-custom-id-2"
  }
  const studentInput={
    studentName: "David",
    id: "Student-custom-id-2"
  }
  //create new club
  await API.graphql(graphqlOperation(createClub,{input:clubInput})); 
  // create new student
  await API.graphql(graphqlOperation(createStudent,{input:studentInput})).then(
    // join student and club
    async()=>{
     await API.graphql(graphqlOperation(createStudentClubJoin,{
       input:{
         clubID: clubInput.id, 
         studentID: studentInput.id
       }
     }))
    }
  );
}

Note: If we don’t pass the ID while mutating, a unique ID is generated automatically.

3.3 Queries

To query data we have- listClubs/listStudents and getClub/getStudent. By using listClubs:, we get the whole list, whereas, by using getClub, we get data specific to the particular ID entered. getClub doesn’t work if no ID is provided.

//using listClubs
query listAllClubs {
  listClubs {
    items {
      clubName
    }
  }
}
//getClub
query GetClub {
  getClub(id: "custom-id-01") {
    clubName
    type
    student {
      items {
        student {
          studentName
        }
      }
    }
   }
}

In our club model, we have a student field, which is connected to the StudentClubJoin table and in the StudentClubJoin table, Student’s ID and Club’s ID is stored. Now, when we want to fetch all the students enrolled in a particular club, we have 2 options:

  1. Use getClubs to get the details of a particular club. getClubs will give us club details (clubName, id, createdAt,updatedAt, type ) and student details (id, studentID, clubID, createdAt and updatedAt). We can then use getStudent to fetch details of students by passing studentID as its required input ID. 
async function getClubDetails(){
  try{  
    await API.graphql(graphqlOperation(getClub,
      {id: "custom-id-c-01"})).then((club)=>{
        console.log('club details are',club.data.getClub);
        const list= club.data.getClub.student.items;
        list.map(async(element)=>(
          await  API.graphql(graphqlOperation(getStudent,{id: element.studentID})).then((data)=>console.log(data))
          ))})
  }
  catch(err){
    console.log('err',err )
  }
}
  1. In src/graphql/queries.js, we have getClub which we will need to change but we can’t change this in queries.js because queries, mutations, and subscription files are updated whenever we make changes in graphql.schema(and by running amplify push command). 
//original
export const getClub = /* GraphQL */ `
 query GetClub($id: ID!) {
   getClub(id: $id) {
     id
     clubName
     type
     student {
       items {
         id
         clubID
         studentID
         createdAt
         updatedAt
       }
       nextToken
     }
     createdAt
     updatedAt
   }
 }
`;

So, we can make a custom file inside src/graphql by any name and change it to the following code:

//new
export const getClub = /* GraphQL */ `
 query GetClub($id: ID!) {
   getClub(id: $id) {
     id
     clubName
     type
     student {
       items {
         id
         clubID
         studentID
       	 student {
       	 studentName
       	 }
         createdAt
         updatedAt
       }
       nextToken
     }
     createdAt
     updatedAt
   }
 }
`;

Now, if we run the following code, we will get the required result: 


async function getClubDetails(){
  try{
    await API.graphql(graphqlOperation(getClub,{id: "custom-id-c-01"})).then((club)=>{
      console.log('club details are',club.data.getClub);
    })
  }
  catch(err){
    console.log('err',err)
  }
}     

The second approach is preferred because it provides data in the required format and also reduces the network calls, however, the first method can also be used.

...and that is how you develop, mutate and query many-to-many relations in AWS Amplify using GraphQL.

Hope this article helps! 

Thanks for reading :)

Subscribe to Our Newsletter

RELATED ARTICLES

More from the engineering frontline.

Dive deep into our research and insights on design, development, and the impact of various trends to businesses.
The Bug That Doesn't Show Up in Code Review: Why Your Flutter Web App Reloads on Safari
The Bug That Doesn't Show Up in Code Review: Why Your Flutter Web App Reloads on Safari
A real-world look at how oversized images can trigger Safari reloads and iOS crashes in Flutter apps and how smarter image decoding prevents them.
From Prompting to Process: What Changed When Flutter Shipped Agent Skills
From Prompting to Process: What Changed When Flutter Shipped Agent Skills
This blog explores how Flutter Agent Skills improve AI-assisted development by combining official framework workflows with project-specific guidance for more consistent development.
Why Everything Your AI Builds Looks the Same
Why Everything Your AI Builds Looks the Same
This blog explores why AI-generated interfaces often look alike and explains how design systems, product context, and reusable engineering practices help teams build distinctive, scalable
How We Built the Missing Bridge from Code to Figma
Technology

Jul 10, 2026

How We Built the Missing Bridge from Code to Figma
This blog explores how AI-generated React apps get turned into fully editable, designer-ready Figma files by reading React Fiber instead of the DOM.
Building a Resilient Hybrid-Cloud Network with WireGuard HA, Route-Based Failover, and Deep Observability
Technology

Jun 27, 2026

Building a Resilient Hybrid-Cloud Network with WireGuard HA, Route-Based Failover, and Deep Observability
A practical breakdown of building resilient AWS-to-on-premises connectivity with WireGuard HA, active-standby failover, and deep packet-forwarding observability.
We Built a 114-Second AWS-to-Azure Failover. Here’s What We Learned
Technology

Jun 19, 2026

We Built a 114-Second AWS-to-Azure Failover. Here’s What We Learned
A practical guide to building a 114-second multi-cloud disaster recovery failover between AWS and Azure — what we built, what broke, and what we learned.
Cloud-Native and Cloud-Agnostic Are Not Ideologies; They Are Business-Stage Decisions
Technology

Jun 12, 2026

Cloud-Native and Cloud-Agnostic Are Not Ideologies; They Are Business-Stage Decisions
This blog explains how organizations can balance speed, scalability, and operational flexibility as they grow from startup to enterprise scale.

The Right Conversation Can Save You Six Months.

Whether you’re navigating AI adoption, modernizing legacy systems, or scaling a product - we start by listening. No pitch deck. No template. A real conversation.