How to Let Users Mark Records as Favorites in Oracle APEX

Store per-user favorites in one table, toggle a star in each report row with an Ajax Callback, and filter the report to the current user's favorites in Oracle APEX.

Favorites let each user pick the records they care about and find them again quickly. A star next to every row is the familiar pattern: click it once to save the record, click it again to remove it, and switch the list to show only your own favorites.

Oracle APEX has no built-in favorites feature, but it is easy to build with one table, an HTML Expression column, an Ajax Callback process and a few lines of JavaScript. The star changes without reloading the page, and every user keeps a separate list.

This guide uses a course catalog as the example. The same steps work for any table, and the favorites table is generic, so one table can hold favorites for several kinds of records.

Step 1: Create the Tables

The example needs two tables. COURSES is the example data. USER_FAVORITES stores one row for each record a user has marked as a favorite:

Example:

create table courses (
    course_id      number generated by default on null as identity,
    title          varchar2(100) not null,
    category       varchar2(30)  not null,
    course_level   varchar2(12)  not null,
    duration_hours number(4,1)   not null,
    constraint courses_pk primary key (course_id),
    constraint courses_title_uk unique (title),
    constraint courses_level_ck check (course_level in ('BEGINNER', 'INTERMEDIATE', 'ADVANCED')),
    constraint courses_duration_ck check (duration_hours > 0)
);

create table user_favorites (
    favorite_id number generated by default on null as identity,
    user_name   varchar2(255) default on null
                    coalesce(sys_context('APEX$SESSION', 'APP_USER'),
                             sys_context('USERENV', 'SESSION_USER')) not null,
    entity_type varchar2(30)  not null,
    entity_id   number        not null,
    created_on  date          default on null sysdate not null,
    constraint user_favorites_pk primary key (favorite_id),
    constraint user_favorites_uk unique (user_name, entity_type, entity_id),
    constraint user_favorites_type_ck check (entity_type in ('COURSE'))
);

create index user_favorites_entity_ix on user_favorites (entity_type, entity_id);

insert into courses (title, category, course_level, duration_hours)
values ('SQL Basics', 'Database', 'BEGINNER', 6);

insert into courses (title, category, course_level, duration_hours)
values ('PL/SQL Programming', 'Database', 'INTERMEDIATE', 12);

insert into courses (title, category, course_level, duration_hours)
values ('Performance Tuning', 'Database', 'ADVANCED', 10);

insert into courses (title, category, course_level, duration_hours)
values ('Building Forms and Reports', 'Oracle APEX', 'BEGINNER', 8);

insert into courses (title, category, course_level, duration_hours)
values ('Dynamic Actions in Depth', 'Oracle APEX', 'INTERMEDIATE', 5);

insert into courses (title, category, course_level, duration_hours)
values ('REST APIs with ORDS', 'Integration', 'INTERMEDIATE', 7);

insert into courses (title, category, course_level, duration_hours)
values ('Writing Clear Reports', 'Soft Skills', 'BEGINNER', 2.5);

insert into courses (title, category, course_level, duration_hours)
values ('Security Best Practices', 'Oracle APEX', 'ADVANCED', 4);

insert into user_favorites (user_name, entity_type, entity_id)
values ('DEMO_DEV', 'COURSE', 2);

insert into user_favorites (user_name, entity_type, entity_id)
values ('SALES_USER', 'COURSE', 2);

insert into user_favorites (user_name, entity_type, entity_id)
values ('SALES_USER', 'COURSE', 4);

commit;

select course_id, title, category, course_level, duration_hours
from   courses
order  by course_id;

select favorite_id, user_name, entity_type, entity_id
from   user_favorites
order  by favorite_id;

Output:

Table COURSES created.

Table USER_FAVORITES created.

Index USER_FAVORITES_ENTITY_IX created.

1 row inserted.

1 row inserted.

1 row inserted.

1 row inserted.

1 row inserted.

1 row inserted.

1 row inserted.

1 row inserted.

1 row inserted.

1 row inserted.

1 row inserted.

Commit complete.

   COURSE_ID TITLE                         CATEGORY       COURSE_LEVEL       DURATION_HOURS
____________ _____________________________ ______________ _______________ _________________
           1 SQL Basics                    Database       BEGINNER                        6
           2 PL/SQL Programming            Database       INTERMEDIATE                   12
           3 Performance Tuning            Database       ADVANCED                       10
           4 Building Forms and Reports    Oracle APEX    BEGINNER                        8
           5 Dynamic Actions in Depth      Oracle APEX    INTERMEDIATE                    5
           6 REST APIs with ORDS           Integration    INTERMEDIATE                    7
           7 Writing Clear Reports         Soft Skills    BEGINNER                      2.5
           8 Security Best Practices       Oracle APEX    ADVANCED                        4

8 rows selected.

   FAVORITE_ID USER_NAME     ENTITY_TYPE       ENTITY_ID
______________ _____________ ______________ ____________
             1 DEMO_DEV      COURSE                    2
             2 SALES_USER    COURSE                    2
             3 SALES_USER    COURSE                    4

A few details in the tables:

  • USER_FAVORITES_UK on USER_NAME, ENTITY_TYPE and ENTITY_ID allows each user to save a record only once. The database enforces this, not the page.
  • ENTITY_TYPE says which table the record belongs to. The check constraint USER_FAVORITES_TYPE_CK lists the allowed types; add a value when you add favorites to another table.
  • USER_NAME and CREATED_ON use DEFAULT ON NULL, so no trigger is needed. USER_NAME takes the APEX user, or the database user outside APEX.
  • The index USER_FAVORITES_ENTITY_IX supports the count of users per record shown in the report.

The sample data has eight courses. DEMO_DEV has saved PL/SQL Programming, and SALES_USER has saved PL/SQL Programming and Building Forms and Reports.

Step 2: Create the Course Catalog Page

In App Builder, open the application and click Create Page. On the Component tab, select Blank Page:

Create a Page wizard with the Blank Page tile marked with a red box
Choose the Blank Page page type.

Set Page Number to 1100 and Name to Course Catalog. Turn Use Breadcrumb off, keep Use Navigation on with Create a new entry, set Icon to fa-star and click Create Page:

Create Blank Page wizard with Page Number 1100, Name Course Catalog, Use Breadcrumb off, Use Navigation on and Icon fa-star marked with red boxes
The page number, name and navigation entry.

Step 3: Add the Course Catalog Report

In Page Designer, right-click Body and choose Create Region. Set Name to Course Catalog and Type to Classic Report. Under Source, keep Type SQL Query and enter this query. Set Page Items to Submit to P1100_SHOW (the item is created in Step 5):

Example:

select c.course_id,
       c.title,
       c.category,
       initcap(c.course_level) as course_level,
       c.duration_hours,
       case when f.favorite_id is null then 'false' else 'true' end as is_favorite,
       case when f.favorite_id is null then 'fa-star-o' else 'fa-star' end as star_icon,
       (select count(*)
        from   user_favorites x
        where  x.entity_type = 'COURSE'
        and    x.entity_id   = c.course_id) as favorite_count
from   courses c
left join user_favorites f
       on  f.entity_type = 'COURSE'
       and f.entity_id   = c.course_id
       and f.user_name   = :APP_USER
where  nvl(:P1100_SHOW, 'ALL') = 'ALL'
or     f.favorite_id is not null
order  by c.title
Course Catalog region with Name, Type Classic Report, Source Type SQL Query, the SQL Query and Page Items to Submit P1100_SHOW marked with red boxes
The report on COURSES with the favorites of the current user.

The outer join adds the favorite row of the current user, if there is one. IS_FAVORITE and STAR_ICON tell the star button whether to show a filled or an empty star. FAVORITE_COUNT counts how many users saved the course.

The WHERE clause shows all courses when P1100_SHOW is ALL, and only the current user's favorites when it is FAVORITES.

Under Advanced, set HTML DOM ID to course-catalog. The JavaScript in Step 8 uses this ID:

Advanced group of the region with HTML DOM ID course-catalog marked with a red box
A fixed ID for the region.

Click the Attributes tab. Under Messages, set When No Data Found to "No favorite courses yet. Click a star to add one.":

Region Attributes with When No Data Found No favorite courses yet. Click a star to add one. marked with a red box
The message for an empty favorites list.

Step 4: Set Up the Report Columns

Expand the region's Columns and select COURSE_ID. Set Type to Hidden Column. Do the same for STAR_ICON. Both stay available as substitution strings for the star column:

COURSE_ID column with Type Hidden marked with a red box
The key is hidden but can still be used as #COURSE_ID#.

Select IS_FAVORITE. Set Heading to Favorite, and set both the heading Alignment and the Layout Column Alignment to center. Under Column Formatting, enter this HTML Expression:

Example:

<button type="button" class="t-Button t-Button--noUI t-Button--iconOnly js-favorite" data-id="#COURSE_ID#" aria-pressed="#IS_FAVORITE#" aria-label="Favorite: #TITLE#" title="Add to or remove from My Favorites"><span class="fa #STAR_ICON#" aria-hidden="true"></span></button>
IS_FAVORITE column with Heading Favorite, Alignment center, Column Alignment center and the HTML Expression marked with red boxes
The star button in every row.

The column shows a button instead of the text true or false. data-id carries the course ID, and aria-pressed tells screen readers whether the star is on. The class js-favorite lets the JavaScript find the buttons.

Set the headings of the other columns to Course, Category, Level, Hours and Saved By. For DURATION_HOURS and FAVORITE_COUNT, set the heading Alignment and Column Alignment to end, so the numbers line up on the right:

FAVORITE_COUNT column with Heading Saved By, Alignment end and Column Alignment end marked with red boxes
The number of users who saved the course.

Step 5: Add the Show Radio Group

Right-click the Course Catalog region and choose Create Page Item. Set Name to P1100_SHOW, Type to Radio Group and Label to Show. Under Settings, set Number of Columns to 2. Under Layout, keep Region Course Catalog with Slot Region Body:

P1100_SHOW item with Name, Type Radio Group, Label Show, Number of Columns 2 and Region Course Catalog marked with red boxes
The filter sits above the report.

Under List of Values, set Type to Static Values. Click Static Values and enter two entries: Display Value All Courses with Return Value ALL, and Display Value My Favorites with Return Value FAVORITES. Turn Display Null Value off:

List of Values with Type Static Values, the values All Courses and My Favorites and Display Null Value off marked with a red box
Two choices: all courses or only favorites.

Under Default, set Type to Static and Static Value to ALL:

Default group with Type Static and Static Value ALL marked with a red box
The page opens with all courses.

Step 6: Refresh the Report When the Filter Changes

Open the Dynamic Actions tab, right-click Change and choose Create Dynamic Action. Set Name to Show Changed. Under When, keep Event Change, set Selection Type to Item(s) and Item(s) to P1100_SHOW:

Dynamic action with Name Show Changed, Event Change, Selection Type Item(s) and Item(s) P1100_SHOW marked with red boxes
The dynamic action runs when the user picks a choice.

Select the true action and set Action to Refresh. Under Affected Elements, set Selection Type to Region and Region to Course Catalog. Turn Fire on Initialization off:

Refresh action with Affected Elements Selection Type Region, Region Course Catalog and Fire on Initialization off marked with red boxes
Only the report is refreshed, not the page.

Because the region submits P1100_SHOW (Step 3), the refreshed report uses the new choice.

Step 7: Create the Ajax Callback Process

Open the Processing tab, right-click Ajax Callback and choose Create Process. Set Name to TOGGLE_FAVORITE and Type to Execute Code, and enter this PL/SQL code:

Example:

declare
    l_course_id courses.course_id%type;
    l_favorite  boolean;
    l_count     pls_integer;
begin
    select max(course_id)
    into   l_course_id
    from   courses
    where  course_id = to_number(apex_application.g_x01 default null on conversion error);

    if l_course_id is null then
        apex_json.open_object;
        apex_json.write('success', false);
        apex_json.write('message', 'This course does not exist.');
        apex_json.close_object;
        return;
    end if;

    delete from user_favorites
    where  user_name   = :APP_USER
    and    entity_type = 'COURSE'
    and    entity_id   = l_course_id;

    l_favorite := sql%rowcount = 0;

    if l_favorite then
        begin
            insert into user_favorites (user_name, entity_type, entity_id)
            values (:APP_USER, 'COURSE', l_course_id);
        exception
            when dup_val_on_index then
                null;
        end;
    end if;

    select count(*)
    into   l_count
    from   user_favorites
    where  entity_type = 'COURSE'
    and    entity_id   = l_course_id;

    apex_json.open_object;
    apex_json.write('success', true);
    apex_json.write('favorite', l_favorite);
    apex_json.write('count', l_count);
    apex_json.close_object;
end;
TOGGLE_FAVORITE process with Name, Type Execute Code, the PL/SQL Code and Execution Point Ajax Callback marked with red boxes
The process that adds or removes a favorite.

The process first checks that the course exists. A value that is not a number becomes NULL instead of raising an error, so a forged ID gets the message "This course does not exist."

Then it deletes the favorite of the current user. If no row was deleted, the course was not a favorite yet, and the process inserts it. DUP_VAL_ON_INDEX covers the rare case where the same user saves the same course twice at the same moment.

The process always uses :APP_USER, never a user name from the browser, so users can only change their own favorites. It returns the new state and the new count as JSON.

Step 8: Add the JavaScript and CSS

Select the page (Page 1100: Course Catalog) in the Rendering tree. Under JavaScript, enter this code in Execute when Page Loads:

Example:

$('#course-catalog').on('click', '.js-favorite', function () {
    var button = this;
    button.disabled = true;
    apex.server.process('TOGGLE_FAVORITE', { x01: button.dataset.id })
        .done(function (data) {
            apex.message.clearErrors();
            if (!data.success) {
                apex.message.showErrors([{ type: 'error', location: 'page', message: data.message }]);
                return;
            }
            if ($v('P1100_SHOW') === 'FAVORITES') {
                apex.region('course-catalog').refresh();
                return;
            }
            button.setAttribute('aria-pressed', data.favorite);
            button.querySelector('.fa').className = 'fa ' + (data.favorite ? 'fa-star' : 'fa-star-o');
            $(button).closest('tr').find('td[headers=FAVORITE_COUNT]').text(data.count);
        })
        .always(function () {
            button.disabled = false;
        });
});
Page JavaScript Execute when Page Loads with the click handler marked with a red box
One click handler for all star buttons.

The handler is attached to the region, not to the buttons. When the report is refreshed, its rows are replaced, but the region element stays, so the new buttons work too.

On a click, the button is disabled and apex.server.process calls TOGGLE_FAVORITE with the course ID in x01. When the answer comes back, the star and the Saved By count of the row are updated in place. In the My Favorites view, the report is refreshed instead, so a removed course disappears from the list.

Under CSS, enter this code in Inline. It makes the stars larger, gray when off and gold when on:

Example:

.js-favorite .fa {
    font-size: 18px;
    color: #8a8f98;
}
.js-favorite .fa-star {
    color: #e8a500;
}
Page CSS Inline with the star styles marked with a red box
The star colors.

Save the page.

Step 9: Test the Favorites

Sign in as DEMO_DEV and open Course Catalog. PL/SQL Programming has a gold star, because DEMO_DEV saved it. Saved By shows 2, because SALES_USER saved it too:

Course Catalog as DEMO_DEV with the gold star of PL/SQL Programming and its Saved By value 2 marked with red boxes
The favorites of DEMO_DEV when the page opens.

Click the star of Dynamic Actions in Depth. The star turns gold and Saved By changes from 0 to 1. The page did not reload; Playwright counted one POST request, the Ajax call:

Course Catalog with the Dynamic Actions in Depth row, now with a gold star and Saved By 1, marked with a red box
The star and the count change in place.

Select My Favorites. The report is refreshed and shows only the two courses DEMO_DEV saved:

Course Catalog with Show My Favorites selected and marked with a red box, listing Dynamic Actions in Depth and PL/SQL Programming
Only the favorites of the current user.

Click the star of PL/SQL Programming. The course is removed from the favorites and disappears from the list:

My Favorites list with only Dynamic Actions in Depth left, marked with a red box
A removed favorite leaves the list at once.

Sign in as SALES_USER. Building Forms and Reports and PL/SQL Programming still have gold stars. The star DEMO_DEV added to Dynamic Actions in Depth is not shown, only its Saved By count of 1:

Course Catalog as SALES_USER with the gold star of Building Forms and Reports and the empty star of Dynamic Actions in Depth marked with a red box
Each user sees only their own stars.

Three more tests checked the edge cases:

  • As SALES_USER, apex.server.process('TOGGLE_FAVORITE') was called from the browser console with x01 set to 999, abc and an empty string. Each call returned success false with the message "This course does not exist." and no row changed.
  • The data-id of the first star was changed to 999 in the browser and the star was clicked. The page showed the error message below.
  • As DEMO_DEV, the star of SQL Basics was double-clicked. Only one request was sent, because the button is disabled while the request runs. The course stayed a favorite after a reload, with Saved By 1.
Error notification 1 error has occurred, This course does not exist. at the top right of the Course Catalog page
A forged course ID is refused.

Favorites are stored in the table, so they survive signing out. When DEMO_DEV signed in again in a new session, the stars were still there and the filter was back on All Courses.

Step 10: Check the Data in SQL

This query lists the favorites with the course titles. It was run after the tests:

Example:

select f.user_name, c.title, to_char(f.created_on, 'YYYY-MM-DD') as created_on
from   user_favorites f
join   courses c on c.course_id = f.entity_id
where  f.entity_type = 'COURSE'
order  by f.user_name, c.title;

After DEMO_DEV added Dynamic Actions in Depth and removed PL/SQL Programming, the favorites of SALES_USER were unchanged:

Output:

USER_NAME     TITLE                         CREATED_ON
_____________ _____________________________ _____________
DEMO_DEV      Dynamic Actions in Depth      2026-10-25
SALES_USER    Building Forms and Reports    2026-10-25
SALES_USER    PL/SQL Programming            2026-10-25

After the forged calls and the double click on SQL Basics, there was exactly one new row:

Output:

USER_NAME     TITLE                         CREATED_ON
_____________ _____________________________ _____________
DEMO_DEV      Dynamic Actions in Depth      2026-10-25
DEMO_DEV      SQL Basics                    2026-10-25
SALES_USER    Building Forms and Reports    2026-10-25
SALES_USER    PL/SQL Programming            2026-10-25

The constraints also stop bad rows that do not come from the page. A second favorite for the same user and course, and an entity type that is not in the list, were both refused:

Example:

insert into user_favorites (user_name, entity_type, entity_id)
values ('DEMO_DEV', 'COURSE', 5);

insert into user_favorites (user_name, entity_type, entity_id)
values ('DEMO_DEV', 'ORDER', 5);

Output:

Error starting at line : 1 File @ b113/05-duplicate.sql
In command -
insert into user_favorites (user_name, entity_type, entity_id)
values ('DEMO_DEV', 'COURSE', 5)
Error report -
ORA-00001: unique constraint (APXSOL.USER_FAVORITES_UK) violated on table
APXSOL.USER_FAVORITES columns (USER_NAME, ENTITY_TYPE, ENTITY_ID)
ORA-03301: (ORA-00001 details) row with column values (USER_NAME:'DEMO_DEV',
ENTITY_TYPE:'COURSE', ENTITY_ID:5) already exists
Help: https://docs.oracle.com/error-help/db/ora-00001/

Error starting at line : 4 File @ b113/05-duplicate.sql
In command -
insert into user_favorites (user_name, entity_type, entity_id)
values ('DEMO_DEV', 'ORDER', 5)
Error report -
ORA-02290: check constraint (APXSOL.USER_FAVORITES_TYPE_CK) violated
Help: https://docs.oracle.com/error-help/db/ora-02290/

Because USER_FAVORITES has no foreign key to COURSES, deleting a course leaves its favorites behind. The report joins to COURSES, so these rows never show up. This script created a course, saved it, deleted it, counted the leftover row and removed it:

Example:

insert into courses (title, category, course_level, duration_hours)
values ('Temporary Course', 'Database', 'BEGINNER', 1);

insert into user_favorites (user_name, entity_type, entity_id)
select 'DEMO_DEV', 'COURSE', course_id from courses where title = 'Temporary Course';

delete from courses where title = 'Temporary Course';

select count(*) as orphan_favorites
from   user_favorites f
where  f.entity_type = 'COURSE'
and    not exists (select 1 from courses c where c.course_id = f.entity_id);

delete from user_favorites f
where  f.entity_type = 'COURSE'
and    not exists (select 1 from courses c where c.course_id = f.entity_id);

commit;

Output:

1 row inserted.

1 row inserted.

1 row deleted.

   ORPHAN_FAVORITES
___________________
                  1

1 row deleted.

Commit complete.

Things to Know

  • The unique constraint is the real protection against double favorites. The disabled button and the DUP_VAL_ON_INDEX handler only make the page behave nicely.
  • A generic table with ENTITY_TYPE cannot have a foreign key. Run the cleanup DELETE from Step 10 in a scheduled job, or use one favorites table per entity with a foreign key ON DELETE CASCADE if you favor only one kind of record.
  • For favorites on another table, add its type to USER_FAVORITES_TYPE_CK and use the same pattern with that type in the query and the process.
  • The Ajax Callback checks the record itself. If users may only see some records, check that rule in the process too, not only in the report query.
  • Attach the click handler to the region, not to the buttons, because a region refresh replaces the rows.
  • P1100_SHOW keeps its value for the session. To remember the filter between sessions, set its Storage to Per User (Persistent).

Related Guides

Conclusion

Favorites in Oracle APEX need one table with a unique constraint, a classic report with a star button in an HTML Expression column, and an Ajax Callback process that adds or removes the row for the current user. A few lines of JavaScript update the star and the count without a page reload.

The tests showed separate lists for two users, a My Favorites filter that updates at once, and forged or repeated requests that changed nothing. The same table and pattern can hold favorites for any other kind of record in the application.

Get more Vinish.dev tutorials on Google

See my articles more often when you search Google.

Vinish Kapoor
Vinish Kapoor

An Oracle ACE, author of four books on Oracle APEX, SQL and PL/SQL, and Oracle Forms, and a software developer building Oracle database applications since 2001.

guest

0 Comments
Oldest
Newest Most Voted