Extensions

Press shift + S to search API reference.

Guide

Build a to-do list extension

Learn how to build a to-do list extension that shows a list of tasks from a table in this 4-part tutorial.

What your extension will look like

Extension showing a to-do list

Here's a breakdown of what we'll cover in each part:

  • Part 1: show the list of tasks.
  • Part 2: allow the user to pick which table they want to show tasks from.
  • Part 3: remember the user's choices for which table they want to show tasks from.
  • Part 4: integrate with a checkbox field to manage the "completed" status of each task.

We'll assume you've already read the getting started guide.

Let's get started!

Part 1

Copy this base, or go to an existing base. If you're using an existing base, make sure it has a table called "Tasks" — if it doesn't, create one or rename one of the existing tables.

Open the extensions panel, click "Install an extension", then click "Build a custom extension". Follow the onscreen instructions to set up the extension.

Airtable user going through the build an extension flow

Open frontend/index.js in your code editor and let's start writing some code! It should look like this:

import {initializeBlock} from '@airtable/blocks/ui';
import React from 'react';
function TodoExtension() {
// YOUR CODE GOES HERE
return <div>Hello world 🚀</div>;
}
initializeBlock(() => <TodoExtension />);

As a first step, let's make the extension show the base name instead of "Hello world." To get information about the base that the extension is running inside, we need the base object.

We can import it directly using import {base} from '@airtable/blocks', but in this case it's better to use a React hook to get the base object. By using a hook, our component will automatically update when the relevant information changes. We'll use the useBase hook, which will cause the TodoExtension component to re-render whenever the base name changes (it will also re-render when tables, fields, and views are created, updated, or deleted and when the current user's permission level changes).

Change index.js to look like this:

import {
initializeBlock,
useBase,
} from '@airtable/blocks/ui';
import React from 'react';
function TodoExtension() {
const base = useBase();
return (
<div>Hello world 🚀</div>
<div>{base.name}</div>
);
}
initializeBlock(() => <TodoExtension />);

The extension should show the base's name and if you rename the base, the extension should automatically update to show the new name!

Showing the number of records

Now we'll change the extension to show the number of records in the "Tasks" table instead of the base name.

Change index.js to look like this:

import {
initializeBlock,
useBase,
useRecords,
} from '@airtable/blocks/ui';
import React from 'react';
function TodoExtension() {
const base = useBase();
const table = base.getTableByName('Tasks');
const records = useRecords(table);
return (
<div>{base.name}</div>
<div>Number of tasks: {records.length}</div>
);
}
initializeBlock(() => <TodoExtension />);

Try creating and deleting records in the table! You should see the number of tasks in the extension update.

Let's walk through the 3 lines that changed:

const table = base.getTableByName('Tasks');

We're asking the base to give us the Table object corresponding to the table called "Tasks". The extension will crash if there isn't a table called "Tasks", and we'll see how to deal with this later in this tutorial. For now we'll assume there will always be a table called "Tasks".

const records = useRecords(table);

Then we use a new hook, called useRecords, to connect our TodoExtension component to the records inside the Table. Any time records are created, deleted, or updated in the table, our component will automatically re-render.

records will be an array of Record objects. Since it's an array, we can use records.length to get the number of records.

return <div>Number of tasks: {records.length}</div>;

Showing the name of the records

Let's change the extension to show the primary cell value of all the records, instead of just the count.

Change index.js to look like this:

import {
initializeBlock,
useBase,
useRecords,
} from '@airtable/blocks/ui';
import React from 'react';
function TodoExtension() {
const base = useBase();
const table = base.getTableByName('Tasks');
const records = useRecords(table);
const tasks = records.map(record => {
return (
<div key={record.id}>
+ {record.name || 'Unnamed record'}
+ </div>
);
});
return (
<div>Number of tasks: {records.length}</div>
<div>{tasks}</div>
);
}
initializeBlock(() => <TodoExtension />);

We're mapping over the Record objects in the records array to create an array of <div> elements, one <div> per record.

Since we're rendering a list, we include a unique key for each element by using the record's ID. Learn more about why React needs keys in lists of elements here.

Inside each <div>, we render the primary cell value of the record. Instead of using record.primaryCellValue, we use record.name to automatically handle converting cell values to string (for example, the table's primary field might be a number field).

record.name might be an empty string, in which case we want to show "Unnamed record" in our list.

You should now see the primary cell value of the records in the table in your extension, and if you edit any of the names from outside the extension, the extension will automatically update to show the latest names.

If you want to get the cell values from other fields, you can use record.getCellValue() or record.getCellValueAsString().

Expanding records

Let's add a button to allow users to expand the record and edit its contents.

Before we do that, let's refactor our extension a little bit. It'll make it easier to add functionality and keep our code more readable if we create a separate Task component, instead of continuing to add code to the top-level TodoExtension component:

import {
initializeBlock,
useBase,
useRecords,
} from '@airtable/blocks/ui';
import React from 'react';
function TodoExtension() {
const base = useBase();
const table = base.getTableByName('Tasks');
const records = useRecords(table);
const tasks = records.map(record => {
return (
<div key={record.id}>
- {record.name || 'Unnamed record'}
- </div>
);
return <Task key={record.id} record={record} />;
});
return (
<div>{tasks}</div>
);
}
function Task({record}) {
return (
<div>
+ {record.name || 'Unnamed record'}
+ </div>
);
}
initializeBlock(() => <TodoExtension />);

Our new Task component takes a prop called record and renders its name, or "Unnamed record" if its name is blank.

Now we can change our Task component to expand the record when the user clicks on a nearby button. We'll do that by using the TextButton component and giving it an onClick handler that calls expandRecord(). To keep this button minimal, we'll label it with an icon instead of text.

import {
initializeBlock,
useBase,
useRecords,
expandRecord,
TextButton,
} from '@airtable/blocks/ui';
import React from 'react';
function TodoExtension() { /* No changes */ }
function Task({record}) {
return (
<div>
{record.name || 'Unnamed record'}
+ <TextButton
+ icon="expand"
+ aria-label="Expand record"
+ onClick={() => {
expandRecord(record);
}}
+ />
</div>
);
}
initializeBlock(() => <TodoExtension />);

Clicking on the expand button in your extension will expand the record, allowing you to edit its name and other fields. You can also use the Tab key on your keyboard to focus on one of the buttons and then press Enter to expand the record.

A little style

One last thing to do before we wrap up Part 1: let's add some CSS styles to make our extension look polished! Feel free to tweak the below styles to customize your extension.

function Task({record}) {
return (
<div>
+ <div
+ style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
fontSize: 18,
padding: 12,
borderBottom: '1px solid #ddd',
}}
+ >
{record.name || 'Unnamed record'}
<TextButton
icon="expand"
aria-label="Expand record"
+ variant="dark"
onClick={() => {
expandRecord(record);
}}
/>
</div>
);
}

Congratulations on finishing Part 1! You should have an extension that looks like this:

Extension with a to-do list showing records from the table

Part 2

The extension we made in Part 1 has a pretty big limitation: it only works if the base has a table called "Tasks." Try renaming the table and you'll see the extension crash.

Let's change the extension to let the user pick which table they want to use to show their tasks!

Don't crash when there's no table

First, we need to change TodoExtension to handle the case where there is no table selected. Instead of crashing, we'll change it to show a blank screen:

function TodoExtension() {
const base = useBase();
const table = base.getTableByName('Tasks');
const table = base.getTableByNameIfExists('Tasks');
const records = useRecords(table);
const tasks = records.map(record => {
return <Task key={record.id} record={record} />;
});
const tasks = records ? records.map(record => {
return <Task key={record.id} record={record} />;
}) : null;
return (
<div>{tasks}</div>
);
}

base.getTableByName will crash the extension if there's no table in the base with the specified name. base.getTableByNameIfExists will return null instead of crashing if there's no table with the specified name.

Now that table might be null, we need to be careful when using it. It's okay to call useRecords with null, but the returned records will also be null. We use a ternary expression to make sure we're only calling records.map when records is not null. If records is null, we won't try rendering any Task components.

const tasks = records ? records.map(record => (
<Task key={record.id} record={record} />
)) : null;

Aside: the above line is equivalent to:

let tasks;
if (records) {
tasks = records.map(record => {
return <Task key={record.id} record={record} />;
});
} else {
tasks = null;
}

We prefer to use ternary expressions for these null checks because they help make the code more concise, but you can write out if statements if you prefer!

Now the extension should work as before, but if you rename the "Tasks" table, the extension should show a blanks screen instead of crashing. Change the name of the table back to "Tasks" and the records should appear again inside the extension.

Storing the selected table in state

Right now we're hard-coding "Tasks" as the table name the extension will use. To let the user specify which table they want to use, we'll store the table name in the TodoExtension component's state with React's built-in useState hook:

import {
initializeBlock,
useBase,
useRecords,
expandRecord,
TextButton,
} from '@airtable/blocks/ui';
import React from 'react';
import React, {useState} from 'react';
function TodoExtension() {
const base = useBase();
const [tableName, setTableName] = useState('Tasks');
const table = base.getTableByNameIfExists('Tasks');
const table = base.getTableByNameIfExists(tableName);
const records = useRecords(table);
const tasks = records ? records.map(record => (
<Task key={record.id} record={record} />
)) : null;
return (
<div>{tasks}</div>
);
}
function Task({record}) { /* No change */ }
initializeBlock(() => <TodoExtension />);

We're still hard-coding "Tasks" as the initial table name, but if we call setTableName with the name of another table, the extension will switch to show records from that table. To make sure make sure that works, we need to add some way for the user to pick a table. The Blocks SDK includes a TablePicker component we can use!

import {
initializeBlock,
useBase,
useRecords,
expandRecord,
TablePicker,
TextButton,
} from '@airtable/blocks/ui';
import React, {useState} from 'react';
function TodoExtension() {
const base = useBase();
const [tableName, setTableName] = useState('Tasks');
const table = base.getTableByNameIfExists(tableName);
const records = useRecords(table);
const tasks = records ? records.map(record => (
<Task key={record.id} record={record} />
)) : null;
return (
<div>
+ <TablePicker
+ table={table}
+ onChange={newTable => {
setTableName(newTable.name);
}}
+ />
{tasks}
</div>
);
}
function Task({record}) { /* No change */ }
initializeBlock(() => <TodoExtension />);

Now there should be a dropdown that lets you pick between different tables in the base (create a new table if you only have 1)!

Block showing a to-do list with a table picker

Using table ID instead of table name

It's definitely an improvement that the user can pick which table to use. But if anyone renames the table, the extension will stop showing the records until you pick the table again. We can avoid this by using the table's ID instead of its name. The table ID won't change when the table gets renamed.

function TodoExtension() {
const base = useBase();
const [tableName, setTableName] = useState('Tasks');
const [tableId, setTableId] = useState(null);
const table = base.getTableByNameIfExists(tableName);
const table = base.getTableByIdIfExists(tableId);
const records = useRecords(table);
const tasks = records ? records.map(record => (
<Task key={record.id} record={record} />
)) : null;
return (
<div>
<TablePicker
table={table}
onChange={newTable => {
setTableName(newTable.name);
setTableId(newTable.id);
}}
/>
{tasks}
</div>
);
}

Now try renaming the selected table. The extension will continue to show records from that table!

Part 3

The user can pick which table they want to show tasks from, but the extension doesn't remember their choice. Every time they load the extension, they start with an empty list until they pick the table. It would be better if the extension remembered the user's choice!

Storing configuration

Each extension installation has a storage mechanism called globalConfig where you can store configuration information. The contents of globalConfig will be synced in real-time to all logged in users of that extension installation. Because any base collaborator can read from it, you shouldn't store sensitive data here.

Airtable's existing extensions, like Page designer and Chart, make heavy use of global config, and your extensions will likely do the same. For example, Airtable's Chart extension lets you choose which kind of chart you want to use (bar chart, pie chart, etc) and it stores the chart type in globalConfig.

Let's change the extension to store the selected table's ID in globalConfig instead of the TodoExtension component's state:

import {
initializeBlock,
useBase,
useRecords,
useGlobalConfig,
expandRecord,
TablePicker,
TextButton,
} from '@airtable/blocks/ui';
import React from 'react';
function TodoExtension() {
const base = useBase();
const [tableId, setTableId] = useState(null);
const globalConfig = useGlobalConfig();
const tableId = globalConfig.get('selectedTableId');
const table = base.getTableByIdIfExists(tableId);
const records = useRecords(table);
const tasks = records ? records.map(record => (
<Task key={record.id} record={record} />
)) : null;
return (
<div>
<TablePicker
table={table}
onChange={newTable => {
setTableId(newTable.id);
globalConfig.setAsync('selectedTableId', newTable.id);
}}
/>
{tasks}
</div>
);
}
function Task({record}) { /* No changes */ }
initializeBlock(() => <TodoExtension />);

Let's walk through the lines that changed:

const globalConfig = useGlobalConfig();

With the useGlobalConfig hook, we can have our TodoExtension component access data in globalConfig and automatically re-render when any of that data changes.

const tableId = globalConfig.get('selectedTableId');

Previously, the table ID was stored in the component state with the useState hook. Now we're storing it in globalConfig, so we get its value by calling globalConfig.get(). We're choosing to use "selectedTableId" as the key in globalConfig, but you could call it whatever you want—it just has to match the key you pass to globalConfig.setAsync() below.

globalConfig.setAsync('selectedTableId', newTable.id);

When the user picks a new table from the TablePicker, we use globalConfig.setAsync() to update the table ID that's stored in globalConfig.

Values in globalConfig can be strings, numbers, booleans, null, arrays, and plain objects—anything that can be encoded as JSON. This means we can't store the table object directly in globalConfig, so we store its ID instead, which is a string.

Now when you pick the table, it'll be saved. If you reload the extension installation, it'll remember the table you were using. Much better!

Permissions

There's a bug in the changes we just made. Read-only and comment-only collaborators aren't allowed to update globalConfig, so if they try changing the selected table our extension will crash. You can try this out by clicking "Simulate," then choosing "Read" or "Comment" from the dropdown:

Airtable user showing simulated permissions in a extension

We could fix this by disabling the TablePicker if the user doesn't have permission to change globalConfig by using globalConfig.checkPermissionsForSetPaths().

But there's an easier way! The TablePicker component has a sibling component called TablePickerSynced which automatically reads and writes to globalConfig with the proper permission checks. Let's switch to that.

import {
initializeBlock,
useBase,
useRecords,
useGlobalConfig,
expandRecord,
TablePicker,
TablePickerSynced,
TextButton,
} from '@airtable/blocks/ui';
import {globalConfig} from '@airtable/blocks';
import React, {useState} from 'react';
function TodoExtension() {
const base = useBase();
const globalConfig = useGlobalConfig();
const tableId = globalConfig.get('selectedTableId');
const table = base.getTableByIdIfExists(tableId);
const records = useRecords(table);
const tasks = records ? records.map(record => (
<Task key={record.id} record={record} />
)) : null;
return (
<div>
- <TablePicker
table={table}
onChange={newTable => {
globalConfig.setAsync('selectedTableId', newTable.id);
}}
/>
+ <TablePickerSynced globalConfigKey="selectedTableId" />
{tasks}
</div>
);
}
function Task({record}) { /* No change */ }
initializeBlock(() => <TodoExtension />);

Instead of passing a table and an onChange prop, we tell TablePickerSynced which key in globalConfig it should read from and write to using the globalConfigKey prop.

Now if you try simulating a "Read" or "Comment" permission level, the table picker will become disabled.

Part 4

As the user accomplishes their tasks, they'll want some way to note their achievement and keep track of what they still have left to do. This is a "to do" application, after all! Let's extend the list so that users can see and update which tasks are complete.

Tracking completed tasks

Each record in the base has a single line text field that describes a task. It should also have a checkbox field that denotes when a task is complete. If your table doesn't have a checkbox field yet, you should add one now so that we can visualize this task status in the extension.

Our extension needs to know which field in the table is the checkbox field. Rather than assuming that we know the exact name or ID of this field, we'll apply the same pattern we used to make the table name configurable. This time, we'll use the FieldPickerSynced component to store the field ID in globalConfig. The Task component will need this field ID, so we'll supply it as a prop, but we'll wait to update the component until the next step.

We'll also add an extra check to verify that the field still exists. If someone deletes the field, we don't want the task trying to lookup a cell value for a non-existent field!

import {
FieldPickerSynced,
initializeBlock,
useBase,
useRecords,
useGlobalConfig,
expandRecord,
TablePickerSynced,
TextButton,
} from '@airtable/blocks/ui';
import {globalConfig} from '@airtable/blocks';
import React, {useState} from 'react';
function TodoExtension() {
const base = useBase();
const globalConfig = useGlobalConfig();
const tableId = globalConfig.get('selectedTableId');
const completedFieldId = globalConfig.get('completedFieldId');
const table = base.getTableByIdIfExists(tableId);
const completedField = table ? table.getFieldByIdIfExists(completedFieldId) : null;
const records = useRecords(table);
const tasks = records ? records.map(record => (
const tasks = records && completedField ? records.map(record => (
<Task key={record.id} record={record} />
<Task key={record.id} record={record} completedFieldId={completedFieldId} />
)) : null;
return (
<div>
<TablePickerSynced globalConfigKey="selectedTableId" />
+ <FieldPickerSynced table={table} globalConfigKey="completedFieldId" />
{tasks}
</div>
);
}
function Task({record}) { /* No change */ }
initializeBlock(() => <TodoExtension />);

Now, the user is able to tell us which field describes whether a task is complete or not. That's nice, but we'll need to update the Task component before they can see the field's effect.

function Task({record}) {
function Task({record, completedFieldId}) {
const label = record.name || 'Unnamed record';
return (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
fontSize: 18,
padding: 12,
borderBottom: '1px solid #ddd',
}}
>
- {record.name || 'Unnamed record'}
+ {record.getCellValue(completedFieldId) ? <s>{label}</s> : label}
<TextButton
icon="expand"
aria-label="Expand record"
variant="dark"
onClick={() => {
expandRecord(record);
}}
/>
</div>
);
}

If the task is complete, we'll wrap its name in an <s> (strikethrough) element, so that browsers and screen readers know that it is no longer relevant. Otherwise, we can render the task name as a normal string. Here's a screen shot of what the extension should look like now:

Todo list extension marks completed tasks as complete with strikethroughs

Perfect!

Making it interactive

If we modify the checkbox values in the table, we can watch the tasks in our extension update in real time. Before we can call this extension "complete", though, we should allow the user to toggle that state from the task list itself.

We need to use the table method updateRecordAsync to modify the record. While we could give each Task component a reference to the whole table, this will make it harder for others to predict the extent of the Task component's behavior. Instead, we'll make a function that can toggle whether a task is complete or not and pass that function to the Task component.

import {
FieldPickerSynced,
initializeBlock,
useBase,
useRecords,
useGlobalConfig,
expandRecord,
TablePickerSynced,
TextButton,
} from '@airtable/blocks/ui';
import {globalConfig} from '@airtable/blocks';
import React, {useState} from 'react';
function TodoExtension() {
const base = useBase();
const globalConfig = useGlobalConfig();
const tableId = globalConfig.get('selectedTableId');
const completedFieldId = globalConfig.get('completedFieldId');
const table = base.getTableByIdIfExists(tableId);
const completedField = table ? table.getFieldByIdIfExists(completedFieldId) : null;
const toggle = (record) => {
table.updateRecordAsync(
record, {[completedFieldId]: !record.getCellValue(completedFieldId)}
);
};
const records = useRecords(table);
const tasks = records && completedField ? records.map(record => (
<Task key={record.id} record={record} completedFieldId={completedFieldId}>
+ <Task
+ key={record.id}
+ record={record}
+ onToggle={toggle}
+ completedFieldId={completedFieldId}
+ />
)) : null;
return (
<div>
<TablePickerSynced globalConfigKey="selectedTableId" />
<FieldPickerSynced table={table} globalConfigKey="completedFieldId" />
{tasks}
</div>
);
}
function Task({record, completedFieldId}) { /* No change */ }
initializeBlock(() => <TodoExtension />);

Now, we can update the Task component to detect when the user interacts with the task (e.g. by clicking on it or pressing the Enter key) and invoking the new function.

function Task({record, completedFieldId}) {
function Task({record, completedFieldId, onToggle}) {
const label = record.name || 'Unnamed record';
return (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
fontSize: 18,
padding: 12,
borderBottom: '1px solid #ddd',
}}
>
+ <TextButton
+ variant="dark"
+ size="xlarge"
+ onClick={() => {
onToggle(record);
}}
+ >
{record.getCellValue(completedFieldId) ? <s>{label}</s> : label}
+ </TextButton>
<TextButton
icon="expand"
aria-label="Expand record"
variant="dark"
onClick={() => {
expandRecord(record);
}}
/>
</div>
);
}

With that change in place, users can modify the records in the table, tracking their progress as they accomplish each task. For more details about modifying data in a base, check out Write back to Airtable.

The end!

We covered a lot of ground, kudos for making it to the end!

Here's a quick recap of the parts of the SDK we used. You can click the links to read more in-depth documentation about each one:

Extra credit

Only showing records from a view

Right now, our extension shows all the records in our Tasks table. It might be more useful to have it only show records from a specific view. Then we can filter the view to control which records we see in the extension. For example, we could create a filter to only show tasks that aren't done yet.

It's easy to switch from showing records in a table to showing records in a view:

import {
FieldPickerSynced,
initializeBlock,
useBase,
useRecords,
useGlobalConfig,
expandRecord,
TablePickerSynced,
TextButton,
ViewPickerSynced,
} from '@airtable/blocks/ui';
import {globalConfig, models} from '@airtable/blocks';
import React, {useState} from 'react';
function getCheckboxField(table, fieldId) { /* No changes */}
function TodoExtension() {
const base = useBase();
const globalConfig = useGlobalConfig();
const tableId = globalConfig.get('selectedTableId');
const viewId = globalConfig.get('selectedViewId');
const completedFieldId = globalConfig.get('completedFieldId');
const table = base.getTableByIdIfExists(tableId);
const view = table ? table.getViewByIdIfExists(viewId) : null;
const completedField = table ? table.getFieldByIdIfExists(completedFieldId) : null;
const records = useRecords(table);
const records = useRecords(view);
const tasks = records && completedField ? records.map(record => (
<Task
key={record.id}
record={record}
onToggle={toggle}
completedFieldId={completedFieldId}
/>
)) : null;
return (
<div>
<TablePickerSynced globalConfigKey="selectedTableId" />
+ <ViewPickerSynced table={table} globalConfigKey="selectedViewId" />
<FieldPickerSynced table={table} globalConfigKey="completedFieldId" />
{tasks}
</div>
);
}
function Task({record, doneField}) { /* No changes */ }
initializeBlock(() => <TodoExtension />);

Just like how we get the Table object by using base.getTableByIdIfExists, we get the View object by using table.getViewByIdIfExists.

You can also pass View to useRecords - the records returned will only contain the records that are visible in that view.

That's it! Try adding filters to the selected view. The records in the extension will automatically get filtered out.