You are viewing a preview of this lesson. Sign in to start learning
Back to Mastering Relay

Declarative Mutation Configs

Using RANGE_ADD, NODE_DELETE, and other configs for common patterns

Declarative Mutation Configs

Master Relay's mutation strategies with free flashcards and spaced repetition practice. This lesson covers declarative mutation configs, optimistic updates, and client-side data consistencyβ€”essential concepts for building predictable, performant GraphQL applications.

Welcome to Declarative Mutation Configs! πŸ’»

When you're working with Relay, mutations don't just change data on the serverβ€”they need to update your local cache intelligently so your UI reflects those changes instantly. Without proper configuration, you'd have to manually refetch queries after every mutation, leading to slow, janky user experiences. That's where declarative mutation configs come in.

Think of declarative configs as instructions you give Relay about how a mutation affects your local data graph. Instead of writing imperative code that manually updates the cache, you declare the relationship between your mutation and existing data. Relay handles the rest, automatically updating components that depend on that data.

This approach is powerful because it separates what changed from how to update the UI. Your mutation knows its business logic; Relay knows the data dependencies. Together, they create a seamless experience where mutations feel instant and UI stays consistent.


Core Concepts

What Are Declarative Mutation Configs? 🎯

Declarative mutation configs are configuration objects you provide when committing a mutation in Relay. They tell Relay's store how to reconcile the mutation response with your existing cached data.

Instead of writing this imperative code:

// Imperative approach (what we want to avoid)
commitMutation(environment, {
  mutation: AddTodoMutation,
  onCompleted: (response) => {
    // Manually fetch the updated list
    refetchQuery();
  }
});

You write this declarative config:

// Declarative approach (what we want!)
commitMutation(environment, {
  mutation: AddTodoMutation,
  configs: [{
    type: 'RANGE_ADD',
    parentID: 'user123',
    connectionInfo: [{
      key: 'TodoList_todos',
      rangeBehavior: 'append'
    }],
    edgeName: 'todoEdge'
  }]
});

The key difference? The declarative config describes the relationship ("this mutation adds to a list") rather than the procedure ("fetch the list again"). Relay figures out which components need updating and handles it automatically.

The Five Config Types πŸ”’

Relay provides five declarative config types, each for a different mutation pattern:

Config Type Use Case Example
NODE_DELETE Remove a node from the graph Delete a todo item
RANGE_ADD Add an edge to a connection Create a new comment
RANGE_DELETE Remove an edge from a connection Remove follower from list
FIELDS_CHANGE Update scalar fields on a node Update user's name
REQUIRED_CHILDREN Ensure specific data is fetched Get updated metadata

πŸ’‘ Pro tip: Most mutations fall into one of these five patterns. If you find yourself needing something more complex, consider using an updater function instead (covered in later lessons).

Understanding NODE_DELETE πŸ—‘οΈ

The simplest config type. When you delete a node (entity) from your backend, you need to remove it from Relay's cache and all connections that reference it.

Structure:

configs: [{
  type: 'NODE_DELETE',
  deletedIDFieldName: 'deletedTodoId'
}]

How it works:

  1. Your mutation returns a field containing the ID of the deleted node
  2. Relay finds that node in the cache
  3. Relay removes it from all connections automatically
  4. Any components rendering that node unmount or show placeholder state

Key parameter:

  • deletedIDFieldName: The field name in your mutation response that contains the deleted node's ID

⚠️ Common mistake: Forgetting to return the deleted ID from your GraphQL mutation. Your mutation must include:

type DeleteTodoPayload {
  deletedTodoId: ID!
}

Understanding RANGE_ADD πŸ“₯

When you create a new item that belongs in a list (connection), RANGE_ADD tells Relay where and how to insert it.

Structure:

configs: [{
  type: 'RANGE_ADD',
  parentID: 'user123',
  connectionInfo: [{
    key: 'TodoList_todos',
    rangeBehavior: 'append'
  }],
  edgeName: 'todoEdge'
}]

Parameters explained:

  • parentID: The ID of the node that owns the connection (e.g., the user who owns the todo list)
  • connectionInfo: Array of connections to update (you can add to multiple lists at once!)
    • key: The connection key from your fragment (usually ComponentName_fieldName)
    • rangeBehavior: Where to insert the new edgeβ€”'append', 'prepend', or 'ignore'
  • edgeName: The field name in your mutation payload containing the new edge

Visual representation:

BEFORE MUTATION:
User (id: user123)
  └─ todos connection
      β”œβ”€ Todo 1
      β”œβ”€ Todo 2
      └─ Todo 3

MUTATION: AddTodo
RETURNS: { todoEdge: { node: { id: 'todo4', text: '...' } } }

AFTER (with rangeBehavior: 'append'):
User (id: user123)
  └─ todos connection
      β”œβ”€ Todo 1
      β”œβ”€ Todo 2
      β”œβ”€ Todo 3
      └─ Todo 4  ← Automatically added!

πŸ’‘ Pro tip: Use 'prepend' for feeds where newest items should appear first (like social media posts), and 'append' for chronological lists (like chat messages).

Understanding RANGE_DELETE πŸ“€

Removes an edge from a connection without deleting the underlying node. Useful when an item should be removed from one list but still exists elsewhere.

Structure:

configs: [{
  type: 'RANGE_DELETE',
  parentID: 'user123',
  connectionKeys: [{
    key: 'FollowersList_followers'
  }],
  pathToConnection: ['user', 'followers'],
  deletedIDFieldName: 'removedFollowerId'
}]

When to use it:

  • Removing a follower from your followers list (they still exist as a user)
  • Removing an item from a shopping cart (product still exists)
  • Unsubscribing from a channel (channel still exists)

Key difference from NODE_DELETE: The node stays in the cache; only the connection edge is removed.

Understanding FIELDS_CHANGE ✏️

When a mutation updates scalar fields on an existing node (like changing a user's name or a todo's completion status), use FIELDS_CHANGE.

Structure:

configs: [{
  type: 'FIELDS_CHANGE',
  fieldIDs: {
    user: 'user123'
  }
}]

How it works:

  1. Your mutation returns updated fields for the node
  2. Relay merges those fields into the existing cached node
  3. All components subscribing to those fields re-render automatically

Example mutation response:

{
  updateUser: {
    user: {
      id: 'user123',
      name: 'Jane Smith',  // Updated
      email: 'jane@example.com'  // Updated
    }
  }
}

Relay sees that user123 already exists in the cache and updates just the name and email fields. Any component rendering user123's name will automatically show the new value.

πŸ’‘ Pro tip: FIELDS_CHANGE is often implicitβ€”if your mutation returns a node with an ID that's already cached, Relay updates it automatically. You only need the config when the updated node isn't directly returned at the root of your mutation payload.

Understanding REQUIRED_CHILDREN πŸ“¦

This config ensures specific data is fetched and available after the mutation, even if it wasn't originally included in your mutation selection set.

Structure:

configs: [{
  type: 'REQUIRED_CHILDREN',
  children: [
    graphql`
      fragment on UpdateUserPayload {
        user {
          profilePicture {
            url
          }
        }
      }
    `
  ]
}]

When to use it:

  • Ensuring analytics data is fetched
  • Pre-loading related data you'll need immediately after the mutation
  • Forcing Relay to track specific fields for subscriptions

⚠️ Warning: Overusing REQUIRED_CHILDREN can lead to over-fetching. Only use it when you genuinely need guaranteed access to specific data post-mutation.

ConnectionInfo and ConnectionKeys πŸ”—

Both RANGE_ADD and RANGE_DELETE work with connectionsβ€”paginated lists in GraphQL. Understanding connection keys is crucial.

Connection key format: ComponentName_fieldName

Given this fragment:

const TodoListFragment = graphql`
  fragment TodoList_user on User {
    todos(first: 10) @connection(key: "TodoList_todos") {
      edges {
        node {
          id
          text
        }
      }
    }
  }
`;

The connection key is "TodoList_todos". This key:

  • Must be globally unique across your app
  • Should include the component name as a prefix (convention)
  • Links your mutation config to the specific connection to update

Multiple connections example:

Sometimes one mutation affects multiple lists:

configs: [{
  type: 'RANGE_ADD',
  parentID: 'project123',
  connectionInfo: [
    { key: 'AllTasks_tasks', rangeBehavior: 'append' },
    { key: 'ActiveTasks_tasks', rangeBehavior: 'prepend' },
    { key: 'MyTasks_tasks', rangeBehavior: 'prepend' }
  ],
  edgeName: 'taskEdge'
}]

This single config adds the new task to three different filtered views of the same data!

Range Behaviors Deep Dive 🎚️

The rangeBehavior parameter controls where new edges appear in a connection. Three options:

Behavior Description Best For
append Add to end of list Chronological logs, chat messages
prepend Add to beginning of list Social feeds, latest first
ignore Don't add to this connection Filtered lists that shouldn't show the new item

Advanced: Dynamic range behaviors

You can also provide a function that returns different behaviors based on connection arguments:

rangeBehavior: (connectionArgs) => {
  if (connectionArgs.orderBy === 'CREATED_DESC') {
    return 'prepend';
  } else if (connectionArgs.orderBy === 'CREATED_ASC') {
    return 'append';
  } else {
    return 'ignore';
  }
}

This handles the same connection with different sorting/filtering applied in different parts of your UI.

Edge vs Node: Understanding Mutation Payloads 🎁

RANGE_ADD requires an edge, not just a node. What's the difference?

Node: The entity itself

{
  id: 'todo4',
  text: 'Buy milk',
  completed: false
}

Edge: A connection wrapper containing the node plus metadata

{
  cursor: 'Y3Vyc29yOjQ=',
  node: {
    id: 'todo4',
    text: 'Buy milk',
    completed: false
  }
}

Your GraphQL mutation must return the edge structure:

type AddTodoPayload {
  todoEdge: TodoEdge!  # ← Returns edge, not just node
}

type TodoEdge {
  cursor: String!
  node: Todo!
}

πŸ’‘ Why edges? Edges carry the cursor needed for pagination. When Relay adds a new edge to a connection, it needs that cursor to maintain pagination state.


Examples

Example 1: Deleting a Todo Item πŸ—‘οΈ

Let's implement a delete mutation with NODE_DELETE config.

GraphQL Mutation:

mutation DeleteTodoMutation($input: DeleteTodoInput!) {
  deleteTodo(input: $input) {
    deletedTodoId
    user {
      id
      todoCount
    }
  }
}

Relay Mutation:

import { graphql, commitMutation } from 'react-relay';

const mutation = graphql`
  mutation DeleteTodoMutation($input: DeleteTodoInput!) {
    deleteTodo(input: $input) {
      deletedTodoId
      user {
        id
        todoCount
      }
    }
  }
`;

function deleteTodo(environment, todoId, userId) {
  commitMutation(environment, {
    mutation,
    variables: {
      input: { todoId }
    },
    configs: [{
      type: 'NODE_DELETE',
      deletedIDFieldName: 'deletedTodoId'
    }],
    optimisticResponse: {
      deleteTodo: {
        deletedTodoId: todoId,
        user: {
          id: userId,
          todoCount: null  // Will be updated from server
        }
      }
    },
    onCompleted: () => {
      console.log('Todo deleted successfully!');
    },
    onError: (error) => {
      console.error('Failed to delete todo:', error);
    }
  });
}

What happens:

  1. User clicks delete button
  2. Optimistic response immediately removes the todo from UI
  3. Request sent to server
  4. Server deletes todo and returns deletedTodoId
  5. Relay finds todoId in cache and removes it from all connections
  6. todoCount on the user object updates with new value from server
  7. All components re-render with updated data

Why this works: The deletedIDFieldName points to the field containing the deleted node's ID. Relay handles the restβ€”finding all references and cleaning them up.

Example 2: Adding a Comment to a Post πŸ’¬

Implementing RANGE_ADD to prepend new comments.

GraphQL Mutation:

mutation AddCommentMutation($input: AddCommentInput!) {
  addComment(input: $input) {
    commentEdge {
      cursor
      node {
        id
        text
        createdAt
        author {
          id
          name
          avatar
        }
      }
    }
    post {
      id
      commentCount
    }
  }
}

Component with Fragment:

const CommentListFragment = graphql`
  fragment CommentList_post on Post {
    id
    comments(first: 20) @connection(key: "CommentList_comments") {
      edges {
        node {
          id
          text
          createdAt
          author {
            name
            avatar
          }
        }
      }
    }
    commentCount
  }
`;

Mutation Implementation:

import { graphql, commitMutation } from 'react-relay';

const mutation = graphql`
  mutation AddCommentMutation($input: AddCommentInput!) {
    addComment(input: $input) {
      commentEdge {
        cursor
        node {
          id
          text
          createdAt
          author {
            id
            name
            avatar
          }
        }
      }
      post {
        id
        commentCount
      }
    }
  }
`;

function addComment(environment, postId, text, currentUserId) {
  const tempId = `temp-comment-${Date.now()}`;
  
  commitMutation(environment, {
    mutation,
    variables: {
      input: { postId, text }
    },
    configs: [{
      type: 'RANGE_ADD',
      parentID: postId,
      connectionInfo: [{
        key: 'CommentList_comments',
        rangeBehavior: 'prepend'  // New comments at top
      }],
      edgeName: 'commentEdge'
    }],
    optimisticResponse: {
      addComment: {
        commentEdge: {
          cursor: tempId,
          node: {
            id: tempId,
            text: text,
            createdAt: new Date().toISOString(),
            author: {
              id: currentUserId,
              name: 'You',  // Will be replaced with real data
              avatar: null
            }
          }
        },
        post: {
          id: postId,
          commentCount: null  // Server will provide updated count
        }
      }
    }
  });
}

Flow diagram:

USER TYPES COMMENT
       β”‚
       ↓
  Optimistic update
  (comment appears immediately)
       β”‚
       ↓
  Server processes mutation
       β”‚
       ↓
  Returns commentEdge
       β”‚
       ↓
  Relay replaces temp ID
  with real server ID
       β”‚
       ↓
  commentCount updates
       β”‚
       ↓
  UI fully consistent βœ…

Key points:

  • prepend makes new comments appear at the top (typical for comment threads)
  • edgeName: 'commentEdge' tells Relay where to find the new edge in the response
  • parentID: postId identifies which post owns the comments connection
  • Optimistic response shows the comment instantly with temporary data

Example 3: Updating User Profile ✏️

Using FIELDS_CHANGE (implicitly) to update scalar fields.

GraphQL Mutation:

mutation UpdateProfileMutation($input: UpdateProfileInput!) {
  updateProfile(input: $input) {
    user {
      id
      name
      bio
      location
      website
    }
  }
}

Mutation Implementation:

import { graphql, commitMutation } from 'react-relay';

const mutation = graphql`
  mutation UpdateProfileMutation($input: UpdateProfileInput!) {
    updateProfile(input: $input) {
      user {
        id
        name
        bio
        location
        website
      }
    }
  }
`;

function updateProfile(environment, userId, profileData) {
  commitMutation(environment, {
    mutation,
    variables: {
      input: {
        userId,
        ...profileData
      }
    },
    // No configs needed! Relay detects the ID and updates automatically
    optimisticResponse: {
      updateProfile: {
        user: {
          id: userId,
          name: profileData.name,
          bio: profileData.bio,
          location: profileData.location,
          website: profileData.website
        }
      }
    },
    onCompleted: (response) => {
      console.log('Profile updated:', response.updateProfile.user);
    },
    onError: (error) => {
      console.error('Update failed:', error);
    }
  });
}

Why no config? When your mutation returns a node with an id field, Relay automatically:

  1. Looks up that ID in the cache
  2. Merges the returned fields into the existing node
  3. Notifies all components subscribed to those fields

You'd only need explicit FIELDS_CHANGE config if the updated node is nested deep in the response and Relay can't automatically detect it.

Optimistic update in action:

USER CLICKS SAVE
      β”‚
      ↓
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Optimistic Response β”‚
β”‚ (instant UI update) β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
           β”‚
           ↓
    Server validates
           β”‚
      β”Œβ”€β”€β”€β”€β”΄β”€β”€β”€β”€β”
      ↓         ↓
   Success   Failure
      β”‚         β”‚
      β”‚         ↓
      β”‚   Rollback to
      β”‚   previous data
      β”‚   + show error
      β”‚
      ↓
 Replace optimistic
 with server data βœ…

Example 4: Removing a Follower πŸ‘₯

Using RANGE_DELETE to remove from a list without deleting the user.

GraphQL Mutation:

mutation RemoveFollowerMutation($input: RemoveFollowerInput!) {
  removeFollower(input: $input) {
    removedFollowerId
    user {
      id
      followerCount
    }
  }
}

Component Fragment:

const FollowersListFragment = graphql`
  fragment FollowersList_user on User {
    id
    followers(first: 50) @connection(key: "FollowersList_followers") {
      edges {
        node {
          id
          name
          avatar
          isFollowing
        }
      }
    }
    followerCount
  }
`;

Mutation Implementation:

import { graphql, commitMutation } from 'react-relay';

const mutation = graphql`
  mutation RemoveFollowerMutation($input: RemoveFollowerInput!) {
    removeFollower(input: $input) {
      removedFollowerId
      user {
        id
        followerCount
      }
    }
  }
`;

function removeFollower(environment, userId, followerId) {
  commitMutation(environment, {
    mutation,
    variables: {
      input: { userId, followerId }
    },
    configs: [{
      type: 'RANGE_DELETE',
      parentID: userId,
      connectionKeys: [{
        key: 'FollowersList_followers'
      }],
      pathToConnection: ['user', 'followers'],
      deletedIDFieldName: 'removedFollowerId'
    }],
    optimisticResponse: {
      removeFollower: {
        removedFollowerId: followerId,
        user: {
          id: userId,
          followerCount: null  // Server will send updated count
        }
      }
    },
    onCompleted: () => {
      console.log('Follower removed');
    }
  });
}

Configuration breakdown:

  • parentID: userId: The user who owns the followers list
  • connectionKeys: Which connection to remove from
  • pathToConnection: Path from mutation root to the connection (for nested structures)
  • deletedIDFieldName: Field containing the ID to remove

Important distinction: The follower user node remains in the cacheβ€”they might appear elsewhere in your UI (like a "suggested users" list). Only the edge connecting them to this user's followers list is removed.


Common Mistakes

❌ Mistake 1: Forgetting to Return the Edge Structure

Wrong GraphQL mutation:

type AddTodoPayload {
  todo: Todo!  # ❌ Returns node, not edge
}

Correct mutation:

type AddTodoPayload {
  todoEdge: TodoEdge!  # βœ… Returns edge with cursor
}

type TodoEdge {
  cursor: String!
  node: Todo!
}

Why it matters: RANGE_ADD requires edges because connections are paginated. Without the cursor, Relay can't maintain pagination state correctly.

❌ Mistake 2: Mismatched Connection Keys

Fragment:

fragment TodoList_user on User {
  todos(first: 10) @connection(key: "TodoList_todos") {
    // ...
  }
}

Wrong config:

configs: [{
  type: 'RANGE_ADD',
  parentID: 'user123',
  connectionInfo: [{
    key: 'TodoList_items',  // ❌ Doesn't match fragment!
    rangeBehavior: 'append'
  }],
  edgeName: 'todoEdge'
}]

Result: The mutation succeeds, but the UI doesn't update because Relay can't find the connection with key "TodoList_items".

Fix: Use the exact same key as in your @connection directive:

key: 'TodoList_todos'  // βœ… Matches fragment

❌ Mistake 3: Using NODE_DELETE When You Need RANGE_DELETE

Scenario: You want to remove an item from one list, but it should still exist elsewhere.

Wrong approach:

configs: [{
  type: 'NODE_DELETE',  // ❌ Deletes from entire cache!
  deletedIDFieldName: 'removedItemId'
}]

Problem: NODE_DELETE removes the node from all connections and the cache entirely. If that item appears in other parts of your UI, it'll disappear there too.

Correct approach:

configs: [{
  type: 'RANGE_DELETE',  // βœ… Only removes from specific connection
  parentID: 'list123',
  connectionKeys: [{ key: 'MyList_items' }],
  pathToConnection: ['list', 'items'],
  deletedIDFieldName: 'removedItemId'
}]

❌ Mistake 4: Incorrect Optimistic Response Shape

Wrong:

optimisticResponse: {
  addTodo: {
    todoEdge: {
      id: 'temp123',  // ❌ Edges don't have IDs!
      text: 'New todo'
    }
  }
}

Correct:

optimisticResponse: {
  addTodo: {
    todoEdge: {
      cursor: 'temp123',  // βœ… Edges have cursors
      node: {  // βœ… The node has the actual data
        id: 'temp123',
        text: 'New todo',
        completed: false
      }
    }
  }
}

Why it matters: Relay expects the optimistic response to match your GraphQL schema exactly. Mismatched shapes cause Relay to ignore the optimistic update or throw errors.

❌ Mistake 5: Not Handling pathToConnection Correctly

Mutation payload:

{
  removeFromList: {
    list: {
      items: {
        // connection here
      }
    },
    removedItemId: 'item456'
  }
}

Wrong config:

configs: [{
  type: 'RANGE_DELETE',
  parentID: 'list123',
  connectionKeys: [{ key: 'ItemList_items' }],
  pathToConnection: ['items'],  // ❌ Incomplete path!
  deletedIDFieldName: 'removedItemId'
}]

Correct config:

pathToConnection: ['list', 'items']  // βœ… Full path from mutation root

The path must trace from the mutation root through any intermediate objects to reach the connection.

⚠️ Warning: Configs Are Additive

You can provide multiple configs for a single mutation:

configs: [
  {
    type: 'RANGE_ADD',
    parentID: 'user123',
    connectionInfo: [{ key: 'TodoList_todos', rangeBehavior: 'append' }],
    edgeName: 'todoEdge'
  },
  {
    type: 'RANGE_ADD',
    parentID: 'project456',
    connectionInfo: [{ key: 'ProjectTodos_todos', rangeBehavior: 'prepend' }],
    edgeName: 'todoEdge'
  }
]

This is powerful but can be confusing. Each config is applied independently, so make sure they don't conflict.


Key Takeaways

🎯 Declarative mutation configs tell Relay what changed, not how to update the UIβ€”Relay figures out the "how" automatically.

πŸ—‘οΈ NODE_DELETE removes nodes entirely from the cache and all connections.

πŸ“₯ RANGE_ADD inserts new edges into connections with control over position (append, prepend, or ignore).

πŸ“€ RANGE_DELETE removes edges from specific connections without deleting the underlying node.

✏️ FIELDS_CHANGE is often implicitβ€”Relay auto-updates nodes when mutations return objects with matching IDs.

πŸ”— Connection keys must exactly match your @connection(key: "...") directives in fragments.

🎁 RANGE_ADD requires edges, not just nodesβ€”your mutation must return the full edge structure with cursor.

⚑ Optimistic responses should mirror your GraphQL schema shape exactly for instant UI updates.

🎯 Choose the right config type based on what your mutation does: create, update, delete from everywhere, or delete from one place.

πŸ’‘ Path matters: pathToConnection must trace from the mutation root to the connection for nested structures.


πŸ“‹ Quick Reference Card: Mutation Config Cheat Sheet

Mutation Action Config Type Key Parameters
Delete item everywhere NODE_DELETE deletedIDFieldName
Add to list RANGE_ADD parentID, connectionInfo, edgeName, rangeBehavior
Remove from specific list RANGE_DELETE parentID, connectionKeys, pathToConnection, deletedIDFieldName
Update fields (implicit) or FIELDS_CHANGE fieldIDs (if needed)
Ensure data fetched REQUIRED_CHILDREN children (fragment array)

Range Behaviors:

  • append β†’ Add to end
  • prepend β†’ Add to beginning
  • ignore β†’ Don't add to this connection

Connection Key Format: ComponentName_fieldName

Edge Structure Required for RANGE_ADD:

{
  cursor: String,
  node: { id, ...fields }
}

Optimistic Response Rules:

  • Must match GraphQL schema shape exactly
  • Use temporary IDs for new items
  • Server response replaces optimistic data

πŸ“š Further Study

  1. Relay Official Documentation - Mutations: https://relay.dev/docs/guided-tour/updating-data/graphql-mutations/
  2. Relay Modern Migration Guide - Mutation Configs: https://relay.dev/docs/migration-and-compatibility/migration-setup/
  3. GraphQL Cursor Connections Specification: https://relay.dev/graphql/connections.htm

πŸ’‘ Next steps: Practice implementing each config type in a small project. Try creating a todo app where you use NODE_DELETE for removing todos, RANGE_ADD for creating them, and experiment with different rangeBehavior settings. Once comfortable with declarative configs, you'll be ready to learn about updater functions for more complex mutation scenarios!