Practice Questions

1. What is a database, and why might a web application need one? Then, explain at least three limitations of using localStorage as a substitute for a proper database.

Solution

A database is a system for storing, organizing, and retrieving structured data. It typically runs on a server, supports multiple concurrent users, and provides guarantees like durability (data survives restarts), consistency (no partial writes), and querying capabilities (filtering, sorting, aggregation). Web applications need a database when data must persist reliably, be shared across users, or be accessed from multiple devices.

localStorage is a browser-based key-value store. It is convenient for small, client-side tasks, but it is not enough as an application data layer:

  1. No cross-device sync. Data is tied to a single browser on a single machine. If you open the app on another device, your data is not there.
  2. No multi-user support. There is no way for multiple users to share or collaborate on the same data.
  3. Data loss risk. Clearing browser data or using incognito mode wipes everything. Users can accidentally lose all their work.
  4. No real-time updates. Even across tabs in the same browser, localStorage does not push updates to other open tabs automatically (you would need manual polling or storage events).
  5. Size limits. localStorage is typically capped at about 5 MB per origin.
  6. No querying. You can only get or set values by key; there is no way to filter, sort, or aggregate data without loading everything into memory and doing it yourself.

2. What is Convex? Describe the problem it solves and how it differs from a traditional backend setup (e.g., Express + a database + REST API).

Solution

Convex is a backend-as-a-service platform that provides a database, server-side functions, and real-time data sync. All of this is available without requiring you to set up or manage a server.

In a traditional backend setup, you would need to:

  • Set up a server framework (e.g., Express)
  • Choose and configure a database (e.g., PostgreSQL, MongoDB)
  • Design and implement a REST or GraphQL API (routes, controllers, serialization)
  • Handle authentication, CORS, connection pooling, and deployment
  • Write your own logic for caching, real-time updates (WebSockets), and error handling

With Convex, you skip all of that. Instead, you write TypeScript functions (queries and mutations) that run on Convex’s servers. The database, API layer, and real-time subscriptions are built in. Your React app connects to Convex through a provider and calls these functions directly using hooks like useQuery and useMutation.

The mental model is: your backend is just TypeScript functions. You define a schema, write functions that read or write data, and Convex handles the rest. This includes hosting, scaling, real-time sync, and type generation.

3. Convex uses validators like v.string(), v.union(), and v.literal() in schema definitions. Given the following schema, explain what values the priority field accepts and what happens if you try to insert a document with priority: "critical".

import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";

export default defineSchema({
  items: defineTable({
    name: v.string(),
    priority: v.union(v.literal("low"), v.literal("medium"), v.literal("high")),
    notes: v.optional(v.string()),
  }),
});
Solution

The priority field accepts exactly three string values: "low", "medium", or "high". The v.union() validator means the value must match one of the provided v.literal() options.

If you try to insert a document with priority: "critical", Convex rejects it at runtime with a validation error – the mutation fails and no document is written. Additionally, TypeScript catches this at compile time as a type error, since the generated types only allow the three literal values. So the value is validated twice, once at compile time and once at runtime.

The notes field uses v.optional(), which means it can be omitted entirely from the document.

4. Write a Convex query function called listByStatus that takes a status string argument and returns all documents from a "tasks" table that match that status.

Solution
import { query } from "./_generated/server";
import { v } from "convex/values";

export const listByStatus = query({
  args: { status: v.string() },
  handler: async (ctx, args) => {
    return await ctx.db
      .query("tasks")
      .filter((q) => q.eq(q.field("status"), args.status))
      .collect();
  },
});

Key points:

  • query() takes an args object (with validators) and a handler function.
  • The handler receives ctx (which provides ctx.db for database access) and args (the validated arguments).
  • .collect() gathers all matching documents into an array.
  • The query is reactive – Convex automatically re-runs it whenever the underlying data changes and pushes updated results to subscribed clients.

5. What is the difference between useQuery in Convex and a typical fetch call inside useEffect? Explain what happens after the initial data is loaded in each case.

Solution

With a fetch call inside useEffect, the component makes a one-time HTTP request when it mounts (or when dependencies change). After the data loads, it sits in local state and never updates unless you explicitly re-fetch – for example, by polling on a timer or re-triggering the effect.

With Convex’s useQuery, the hook opens a persistent subscription to the server. After the initial data loads, the connection stays open. When any client changes the underlying data (via a mutation), Convex automatically re-runs the query on the server and pushes the updated result to all subscribed clients. The component re-renders with the new data without any manual refetching.

So fetch is one request and one response. Convex’s useQuery is a live subscription that keeps the UI in sync with the database as the data changes.

6. Write a Convex mutation called remove that takes an id argument (of type v.id("projects")) and deletes the corresponding document. Then show how you would call this mutation from a React component using useMutation.

Solution

The mutation:

import { mutation } from "./_generated/server";
import { v } from "convex/values";

export const remove = mutation({
  args: { id: v.id("projects") },
  handler: async (ctx, args) => {
    await ctx.db.delete(args.id);
  },
});

Calling it from a React component:

import { useMutation } from "convex/react";
import { api } from "../convex/_generated/api";

function ProjectCard({ projectId }: { projectId: Id<"projects"> }) {
  const removeProject = useMutation(api.projects.remove);

  const handleDelete = async () => {
    await removeProject({ id: projectId });
  };

  return <button onClick={handleDelete}>Delete</button>;
}

Key points:

  • v.id("projects") validates that the argument is a real document ID from the "projects" table.
  • useMutation returns an async function you call with the args object.
  • After the mutation executes, any useQuery subscriptions that read from the "projects" table automatically update – you do not need to manually refetch.

7. Convex mutations are described as transactional. Explain what this means. What happens if an error occurs partway through a mutation that performs multiple database operations?

Solution

A transactional mutation means all database operations within the handler execute as a single atomic unit. They either all succeed or all fail. There is no in-between state.

If an error occurs partway through a mutation (for example, the mutation inserts a document, then patches another, and the patch throws an error), none of the changes are committed to the database. The insert is rolled back as if it never happened. This prevents the database from ending up in an inconsistent state where some operations completed and others did not.

This is the same guarantee that traditional databases provide with transactions. You do not have to undo partial operations yourself when something fails.

8. Convex generates a Doc<"tableName"> type from your schema. What advantage does using Doc<"tasks"> for component props have over defining your own Task type manually? What fields does Doc include that you might not put in a hand-written type?

Solution

Using Doc<"tasks"> creates a single source of truth for the shape of a task. If you change the schema (e.g., add a dueDate field), the generated type updates automatically, and TypeScript flags every component that needs to handle the new field. With a hand-written Task type, you have to remember to update it separately. If you forget, the type and the actual data no longer match, and nothing tells you.

Doc<"tasks"> also includes Convex system fields that you would not typically put in a manual type:

  • _id – a typed document ID (Id<"tasks">)
  • _creationTime – a numeric timestamp of when the document was created

These fields are added by Convex automatically and are always present on every document.

9. A user clicks a “Mark as favorite” button in your app. The mutation updates the item on the server, but because useQuery waits for the server round-trip, the heart icon does not fill in for a noticeable delay. Explain what an optimistic update is and write code that uses Convex’s .withOptimisticUpdate() to make the UI respond instantly.

Assume you have a query api.items.list (no args) that returns an array of items, and a mutation api.items.toggleFavorite that takes { id: v.id("items") } and flips the isFavorite boolean on the server.

Solution

An optimistic update is a technique where the UI applies the expected result of a mutation to the local query cache immediately, before the server confirms it. The user sees the change right away. When the server responds, its authoritative result replaces the optimistic value. If the mutation fails, the optimistic change is automatically rolled back.

const toggleFavorite = useMutation(
  api.items.toggleFavorite,
).withOptimisticUpdate((localStore, args) => {
  const items = localStore.getQuery(api.items.list, {});
  if (items) {
    localStore.setQuery(
      api.items.list,
      {},
      items.map((item) =>
        item._id === args.id ? { ...item, isFavorite: !item.isFavorite } : item,
      ),
    );
  }
});

Key points:

  • .withOptimisticUpdate() receives a callback with localStore (the client-side query cache) and args (the mutation arguments).
  • localStore.getQuery() reads the current cached result for a query. You must pass the same query reference and args that the component uses.
  • localStore.setQuery() writes a new value into the cache. The update must be immutable – you create a new array with .map() rather than mutating the existing one.
  • The heart icon fills in instantly because React re-renders from the updated cache. The server round-trip happens in the background.

10. You are evaluating two npm packages that solve the same problem. Package A has 2 million weekly downloads, 15,000 GitHub stars, and its last commit was 14 months ago with 200 open issues. Package B has 100,000 weekly downloads, 3,000 stars, and its last commit was 2 weeks ago with 30 open issues. Which factors would weigh most heavily in your decision, and why?

Solution

The most important factors to weigh here:

  1. Maintenance activity – Package A has not been updated in over a year with 200 open issues, which suggests it may be abandoned or under-maintained. Package B is actively maintained (committed 2 weeks ago) with a manageable issue count. For any library you depend on, active maintenance means security patches, bug fixes, and compatibility with newer tooling.

  2. Downloads vs. stars – Package A’s high download count may reflect legacy adoption (many projects already depend on it) rather than current recommendation. Downloads alone do not tell you if new projects are choosing it. Stars indicate historical interest but not ongoing quality.

  3. Issue responsiveness – The ratio matters more than the raw number. 200 open issues with no recent commits suggests issues are piling up. 30 open issues with recent activity suggests the maintainers are responsive.

  4. Bundle size and documentation – You should check these too, but the scenario does not give them.

In this case, Package B is likely the better choice despite lower adoption numbers. A library that is still being maintained, with maintainers who respond, is more reliable in the long run than one that is popular but no longer updated. However, you should also check if Package A is “done” (stable, no bugs, simply complete) rather than abandoned – some libraries do not need frequent updates.

11. Explain the difference between a Convex dev deployment and a production deployment. Why would you use a deploy key for production instead of logging in interactively?

Solution

A dev deployment is created when you run npx convex dev. It is tied to your personal Convex account, syncs functions automatically as you edit them, and is meant for local development. Changes push instantly as you save files.

A production deployment is created with npx convex deploy. It is a separate, stable environment that serves your live users. You push function updates to it deliberately, not on every file save.

You use a deploy key for production (instead of interactive login) because:

  1. CI/CD environments are non-interactive – automated systems like GitHub Actions cannot open a browser to log in. A deploy key is a token stored as an environment secret that authenticates without user interaction.
  2. Security – a deploy key can be scoped to deployment only, limiting what it can do. You do not need to store your full account credentials in CI.
  3. Reproducibility – automated deployments ensure the same process runs every time, reducing the risk of human error during manual deploys.

The typical pattern is: store the deploy key as a GitHub secret (e.g., CONVEX_DEPLOY_KEY), and reference it in your GitHub Actions workflow so that npx convex deploy can authenticate automatically.