Auto-Save Draft Feature in Oracle APEX Forms
Silently save form data every 30 seconds - no user action needed.
Ever filled a long form and lost everything because the session timed out or you accidentally closed the tab? I've seen users completely redo a 10-field form just because of a timeout. That's when I decided to build a proper auto-save draft - one that works silently, without the user doing anything.
Wait - Doesn't APEX Already Handle This?
Yes, and this was my first thought too. When you open any page item in APEX and look at its properties, there's a Storage setting with three options. Let me walk through what each one actually does, because understanding this is exactly why we're building something custom.
Per Request (Memory Only) - This is the default for most items. The value exists only during the current server request, and once that's done, APEX forgets it. Now, this is actually fine for normal form submits - when a user clicks the Submit button, APEX does a full page submit and all item values are sent to the server together, so session state gets updated correctly. The problem is with AJAX calls. When you call apex.server.process() in JavaScript, that's a background request - APEX doesn't automatically include item values in it. If you need an item's value on the server side during an AJAX call, you have to explicitly list it in "Page Items to Submit". So Per Request works fine for regular submits, but the moment you go async, you're on your own. For our auto-save feature which is entirely AJAX-based, this default simply doesn't work.
Per Session (Persistent) - This saves the item value in the APEX session state table in the database, tied to the user's current session ID. So if the user navigates away and comes back within the same session, the value is still there. Sounds useful - but sessions expire. Once the session times out (typically 8 hours of inactivity by default), everything is gone. Also, there's no concept of "this is a draft" vs "this is already submitted". The values just sit there, and the next time the user opens the form, APEX restores them automatically - even if that form was already submitted successfully last week. That's the real problem.
Per User (Persistent) - This goes one step further. Values are stored against the username itself, not the session. So even if the session expires, even if the user logs out and comes back days later on a different device, the values are restored. On paper this sounds like exactly what we need. But here's the catch: you have absolutely no control over when those values get cleared. After a successful form submission, APEX doesn't know to wipe them. The user submits the form, comes back tomorrow to fill a new entry, and APEX silently restores all the old values into the fields - without telling the user. No "draft restored" message, no way to discard, no audit trail. That's a UX nightmare waiting to happen.
So Why a Custom Table?
With a custom apex_form_drafts table, we control everything. We decide when the draft is saved, when it's restored, and - most importantly - when it's deleted. After a successful submit, we explicitly delete the draft. Next time the user opens the form, it's clean. We also get to show the user a "Your previous draft has been restored" message, which Per User session state can never do because APEX restores those values silently before the page even loads.
On top of that, the custom table is reusable across every form in your entire application - just one table, differentiated by app_id and page_id. And if a user ever reports "I lost my data," you can actually query the table and check. With session state, there's nothing to debug.
That's the full picture. Now let's build it.
1 Create the Draft Table
One table for your entire application. The UNIQUE constraint on (app_id, page_id, app_user) means one active draft per user per page - no duplicates, no mess.
CREATE TABLE apex_form_drafts ( draft_id NUMBER GENERATED ALWAYS AS IDENTITY PRIMARY KEY, app_id NUMBER NOT NULL, page_id NUMBER NOT NULL, app_user VARCHAR2(255) NOT NULL, draft_data CLOB, created_on TIMESTAMP DEFAULT SYSTIMESTAMP, updated_on TIMESTAMP DEFAULT SYSTIMESTAMP, CONSTRAINT uq_draft UNIQUE (app_id, page_id, app_user) ); CREATE INDEX idx_draft_user ON apex_form_drafts(app_id, page_id, app_user);
2 Page Items Setup
Set up your form page (Page 29 in my case) with these items. The important one is P8_LAST_SAVED - it's Display Only and shows "Draft auto-saved at HH:MM:SS". No DB column needed for it.
| Item Name | Type |
|---|---|
| P8_FULL_NAME | Text Field |
| P8_EMAIL | Text Field |
| P8_PHONE_NUMBER | Text Field |
| P8_DEPARTMENT | Select List |
| P8_NOTES | Textarea |
| P8_LAST_SAVED | Display Only - no DB column |
3 App Process - SAVE_DRAFT
Create an Application Process (not page process) with Point set to Ajax Callback. It receives the draft JSON via g_x01, uses MERGE to insert or update, then returns a timestamp back to JavaScript.
DECLARE l_data CLOB := apex_application.g_x01; BEGIN MERGE INTO apex_form_drafts d USING DUAL ON ( d.app_id = :APP_ID AND d.page_id = :APP_PAGE_ID AND d.app_user = :APP_USER ) WHEN MATCHED THEN UPDATE SET draft_data = l_data, updated_on = SYSTIMESTAMP WHEN NOT MATCHED THEN INSERT (app_id, page_id, app_user, draft_data) VALUES (:APP_ID, :APP_PAGE_ID, :APP_USER, l_data); COMMIT; apex_json.open_object; apex_json.write('status', 'saved'); apex_json.write('savedAt', TO_CHAR(SYSTIMESTAMP, 'HH24:MI:SS')); apex_json.close_object; END;
4 App Process - LOAD_DRAFT
Same setup - Ajax Callback. Called on page load to check if a draft exists for this user. Returns hasDraft: true with the data, or false if nothing found.
DECLARE l_data CLOB; BEGIN BEGIN SELECT draft_data INTO l_data FROM apex_form_drafts WHERE app_id = :APP_ID AND page_id = :APP_PAGE_ID AND app_user = :APP_USER; EXCEPTION WHEN NO_DATA_FOUND THEN l_data := NULL; END; apex_json.open_object; IF l_data IS NOT NULL THEN apex_json.write('hasDraft', TRUE); apex_json.write('data', l_data); ELSE apex_json.write('hasDraft', FALSE); END IF; apex_json.close_object; END;
5 App Process - CLEAR_DRAFT
Deletes the draft row. Called when the user clicks "Discard Draft" or after a successful form submit.
BEGIN DELETE FROM apex_form_drafts WHERE app_id = :APP_ID AND page_id = :APP_PAGE_ID AND app_user = :APP_USER; COMMIT; apex_json.open_object; apex_json.write('status', 'cleared'); apex_json.close_object; END;
6 Dynamic Action - Auto-Save Timer
Create a Dynamic Action: Event → Page Load, Fire on Init → Yes. This sets a 30-second interval that collects all field values and sends them to SAVE_DRAFT. The interval ID is saved on window so we can cancel it later on submit.
var pageItems = [ 'P8_FULL_NAME', 'P8_EMAIL', 'P8_PHONE_NUMBER', 'P8_DEPARTMENT', 'P8_NOTES' ]; function collectDraftData() { var draft = {}; pageItems.forEach(function(id) { draft[id] = apex.item(id).getValue(); }); return JSON.stringify(draft); } function saveDraft() { apex.server.process('SAVE_DRAFT', { x01: collectDraftData() }, { dataType: 'json', success: function(data) { if (data.status === 'saved') { apex.item('P8_LAST_SAVED').setValue('Draft auto-saved at ' + data.savedAt); } }, error: function() { console.warn('Auto-save failed. Will retry next interval.'); } }); } // Save every 30 seconds window._autoSaveInterval = setInterval(saveDraft, 30000);
7 Dynamic Action - Restore Draft on Page Load
A second Dynamic Action on Page Load. It calls LOAD_DRAFT and checks if a draft exists. If one does, it loops through each field and sets the value - but it only shows the "Your previous draft has been restored" message if at least one field actually had data. This matters because the draft row can exist in the table but all fields might be empty (if the user opened the form, waited, and never typed anything). Showing a success message in that case would just confuse people.
apex.server.process('LOAD_DRAFT', {}, { dataType: 'json', success: function(data) { if (!data.hasDraft || !data.data) { return; } var draft = JSON.parse(data.data); var restored = false; Object.keys(draft).forEach(function(id) { if (apex.item(id) && draft[id] !== '' && draft[id] !== null) { apex.item(id).setValue(draft[id]); restored = true; } }); // Only show the message if at least one field had actual data if (restored) { apex.message.showPageSuccess('Your previous draft has been restored.'); } } });
8 Discard Draft Button
Button settings: Action → Defined by Dynamic Action, Style → Danger. The Dynamic Action on click shows a confirmation, calls CLEAR_DRAFT, clears all fields, and stops the timer.
apex.confirm('Discard your saved draft? This cannot be undone.', { title: 'Discard Draft', okLabel: 'Yes, Discard', cancelLabel: 'Cancel', callback: function(confirmed) { if (confirmed) { apex.server.process('CLEAR_DRAFT', {}, { dataType: 'json', success: function() { ['P8_FULL_NAME','P8_EMAIL','P8_PHONE_NUMBER', 'P8_DEPARTMENT','P8_NOTES','P8_LAST_SAVED'] .forEach(function(id) { apex.item(id).setValue(''); }); apex.message.showPageSuccess('Draft discarded.'); clearInterval(window._autoSaveInterval); } }); } } });
9 Clear Draft After Final Submit
Add a Page Process on your submit page - Type: Execute Code, Point: Processing - with a higher sequence than your main save process. This runs after the form data is saved and deletes the draft so users don't see a stale restore message next time.
BEGIN DELETE FROM apex_form_drafts WHERE app_id = :APP_ID AND page_id = :APP_PAGE_ID AND app_user = :APP_USER; COMMIT; END;
10 Optional: Cleanup Old Drafts
Without this, the table slowly fills up with drafts from users who never came back. This scheduler job runs at 2 AM daily and purges anything older than 7 days - totally set and forget.
BEGIN DBMS_SCHEDULER.CREATE_JOB( job_name => 'CLEANUP_OLD_DRAFTS', job_type => 'PLSQL_BLOCK', job_action => ' BEGIN DELETE FROM apex_form_drafts WHERE updated_on < SYSTIMESTAMP - INTERVAL ''7'' DAY; COMMIT; END;', start_date => SYSTIMESTAMP, repeat_interval => 'FREQ=DAILY;BYHOUR=2;BYMINUTE=0', enabled => TRUE, comments => 'Remove APEX form drafts older than 7 days' ); END;
That's It!
Ten steps and your forms now silently protect everything your users type. Once you set this up the first time, reusing it across other pages takes maybe 10 minutes - just point it at a different page and update the item names.
The "draft restored" message is honestly the detail users appreciate most. It tells them the system is paying attention. Small touch, big impact.
Full source code is on GitHub and you can try it live via the demo link. Drop any questions in the comments - happy to help you adapt this to your own setup.
Comments
Post a Comment