All articles
Select
Data source
Form Builder

Select data sources in NgStarter UI

Load select options lazily from an API, keep selected values renderable before the panel opens, and expose the same data source to Form Builder select fields.

June 19, 20268 min read

Why use a data source?

Static ngs-option elements are perfect for short lists. A data source is better when options come from a server, the list is large, or users need remote search. The select calls your function when it needs data, passes paging and search state, and renders the returned items as options.

The data source contract

A SelectDataSource receives a request with search, page, pageSize, selectedValues, reason, and an optional signal. The important rule is that the first request must be able to return the currently selected option. That lets the trigger show a label even when the option is not part of the first loaded page.

users-data-source.ts
import { SelectDataSource, SelectDataSourceOption } from '@ngstarter-ui/components/select';

interface UserOption {
  id: string;
  name: string;
  team: string;
}

const usersDataSource: SelectDataSource<UserOption> = async request => {
  const response = await usersApi.search({
    search: request.search,
    page: request.page,
    pageSize: request.pageSize,
    selectedValues: request.selectedValues,
    signal: request.signal,
  });

  const selected = request.reason === 'initial'
    ? await usersApi.findByIds(request.selectedValues)
    : [];

  return {
    items: toOptions([...selected, ...response.items]),
    hasMore: response.hasMore,
    nextCursor: response.nextPage,
  };
};

function toOptions(users: UserOption[]): SelectDataSourceOption<UserOption>[] {
  const seen = new Set<string>();

  return users
    .filter(user => {
      if (seen.has(user.id)) {
        return false;
      }

      seen.add(user.id);
      return true;
    })
    .map(user => ({
      label: `${user.name} - ${user.team}`,
      value: user.id,
      data: user,
    }));
}

Use it in ngs-select

Pass the function to [dataSource]. Enable searchable when the panel should include the remote search input. pageSize controls how many options load per request, and minSearchLength avoids sending short search terms to the API.

owner-select.html
<ngs-form-field>
  <ngs-label>Owner</ngs-label>
  <ngs-select
    [formControl]="owner"
    [dataSource]="usersDataSource"
    [pageSize]="20"
    searchable
    [minSearchLength]="1">
    <ng-template ngsOptionContentDef let-user let-label="label">
      <strong>{{ user?.name || label }}</strong>
      <span>{{ user?.team }}</span>
    </ng-template>

    <ng-template ngsSelectValueDef let-user let-label="label">
      {{ user?.name || label }}
    </ng-template>
  </ngs-select>
</ngs-form-field>

Register it for Form Builder

Form Builder select fields use named data sources. Register each source with an id, a human-readable name, and the same SelectDataSource function. The name is what editors see in the select field inspector.

form-builder-providers.ts
import {
  provideFormBuilderSelectDataSource,
} from '@ngstarter-ui/components/form-builder';

@Component({
  providers: [
    provideFormBuilderSelectDataSource({
      id: 'users',
      name: 'Users',
      dataSource: usersDataSource,
      optionContentComponent: UserOptionComponent,
      valueComponent: UserValueComponent,
    }),
  ],
})
export class FormBuilderScreen {}

Save the selected data source in the schema

A Form Builder select field stores optionsSource: 'dataSource' and the registered data source id. Static custom options still use optionsSource: 'static' and the regular options array.

form-builder-schema.ts
const schema: FormBuilderSchema = {
  sections: [
    {
      id: 'assignment',
      title: 'Assignment',
      fields: [
        {
          id: 'owner',
          name: 'owner',
          type: 'select',
          label: 'Owner',
          optionsSource: 'dataSource',
          dataSource: 'users',
        },
      ],
    },
  ],
};

Custom option and value templates

Direct ngs-select usage can customize async rows with ngsOptionContentDef and selected values with ngsSelectValueDef. Form Builder uses component classes instead, registered as optionContentComponent and valueComponent on the data source definition.

user-option.component.ts
@Component({
  selector: 'app-user-option',
  template: `
    <div class="flex flex-col">
      <strong>{{ data?.name || label }}</strong>
      <span>{{ data?.team }}</span>
    </div>
  `,
})
export class UserOptionComponent {
  readonly data = input<UserOption | null>(null);
  readonly label = input('');
}

Implementation checklist

  • Return selected options on the initial request when selectedValues is not empty.
  • Deduplicate selected options and page results before returning items.
  • Respect request.signal when your HTTP client supports aborting stale requests.
  • Use stable option values such as database ids, not labels.
  • Keep Form Builder data source ids stable because schemas persist them.