25 Mar 2024 • 10 min read
This article covers more advanced topics and assumes that you have a working knowledge of React and CodeMirror. I would recommend reading Learning CodeMirror and CodeMirror and React as a primer.
When working in CodeMirror and React, I often found challenges around state management. Both systems want to own the state and act as the source of truth. There are a few ways that we can sync state, a few of which I covered in my CodeMirror and React article. This offers another way to sync state–a method I call the "state coordinator" extension.
Let's say that we want to build a SQL terminal for our database. This terminal will be a CodeMirror editor (of course!) and has a few particular UX requirements:
run
button that lets you run the query. Additionally, users should be able to run a query with a keyboard shortcut, in this case, Ctrl-Enter
.Here's the final version of what we are building:
We face a few challenges in building this editor:
Now that we have a clear picture of the requirements for our SQL terminal, let's start building.
I won't walk through every line of code required to make this terminal, as there is a fair amount of scaffolding and styling that are required to understand it. The code is publically available on GitHub if you want to read through it.
First we'll put together a way to describe the terminal state. In this trivial example, we'll use a very simple type called, you guessed it: State
.
type State = 'valid query' | 'query executed' | 'error' | 'loading';
We can use this throughout the editor to describe its state. There are some places we will need to get this state out of CodeMirror to React. We could set up a StateField
and StateEffect
(like in this example), but in this case, I want to simply emit the state and have React respond to it. For that, I'll use a Facet with a callback as the Facet type:
const emitStateToReact = Facet.define<(state: State) => void>();
function emitState(state: State, editorState: EditorState) {
const [emit] = editorState.facet(emitStateToReact);
emit(state);
}
I've also included a little helper function, emitState
, that makes using this facet a bit more convenient throughout the code.
With our state in place, we can move next to linting. Let's start with a simple function to check the presence of a wildcard in a query:
function checkQueryForWildcard(query: string): Diagnostic | null {
const wildcardIndex = query.indexOf('*');
if (wildcardIndex === -1) {
return null;
}
return {
from: wildcardIndex,
to: wildcardIndex + 1,
severity: 'error',
message:
'Wildcard selectors are disallowed. Please specify the columns you want to select.',
};
}
This function outputs an object that conforms to the Diagnostic
type, which is the type that CodeMirror uses in its linter to describe an error. With that in place, let's also use the CodeMirror linter
function to lint our code:
const sqlLinter = linter(
(view: EditorView) => {
const document = view.state.doc.toString();
const wildcardError = checkQueryForWildcard(document);
if (wildcardError == null) {
emitState('valid query', view.state);
return [];
}
emitState('error', view.state);
return [wildcardError];
},
{
delay: 2500,
},
);
You'll notice I set the delay to 2500
–this is to make the UI example really obvious, as 2.5 seconds is long enough to see the time between end of keystroke and when the lint cycle happens.
I'll also make a little watcher to reset the React state as soon as we no longer have any lint errors. This uses CodeMirror's transaction filter–I'll explain more about this particular function later, as we'll use it in our state coordinator.
const clearErrorOnDocChange = EditorState.transactionFilter.of(
(transaction) => {
if (transaction.docChanged && diagnosticCount(transaction.state) < 1) {
emitState('valid query', transaction.state);
}
return transaction;
},
);
At this point, we have enough building blocks in place to work on the first piece of our state coordinator puzzle. For this, we'll use a CodeMirror Annotation
. The docs give the definition of an annotation:
Annotations are tagged values that are used to add metadata to transactions in an extensible way.
The problem of "triggering an event" could also be solved with a StateEffect
, but because of the limitations around how state effects can be used, I opted to use an annotation.
Let's start by creating our annotation:
const triggerEvent = Annotation.define<'click' | 'keyboard'>();
This annotation lets us tell CodeMirror that an event has been triggered and also gives us the ability to describe the source of the trigger. We can set up a keyboard shortcut that is bound to Ctrl-Enter
to kick off the event with keyboard
as the source:
const keyboardShortcuts = keymap.of([
{
key: 'Ctrl-Enter',
run: (view: EditorView) => {
// 1
if (diagnosticCount(view.state) > 0) {
return true;
}
// 2
view.dispatch({annotations: triggerEvent.of('keyboard')});
return true;
},
},
]);
A couple of notes about this code:
view.dispatch
is how we kick off an event within CodeMirror. We create a new instance of our Annotation with the .of()
method and provide the source of the event.Now that we are sending events, let's make a state coordinator to handle these events!
Here's the mental model for how our state coordinator will connect to CodeMirror and React:
I use the term "state coordinator" in more of a conceptual way, as there is no "state coordinator" class or object within CodeMirror. Instead, we will build an extension that coordinates the state between CodeMirror and React and ensures both end up with the correct state information.
When building this, I needed to find an extension that would run as part of every transaction so that I could watch for the dispatched annotation and act on it. I eventually settled on transaction filter as the pick. This facet produces an extension that runs on each transaction, and lets us provide additional transactions as needed. This is ideal for cases where we need to modify the state in some way as part of our state coordinator.
The downside of using a transactionFilter
is that it does not run for transactions that set filter: false
. I found this was an acceptable tradeoff for this use case, but there also exists transactionExtender
which is more limited than transactionFilter
but is guaranteed to run on every transaction.
Here's what the code for our state coordinator looks like. I'll explain each step in detail:
const stateCoordinator = EditorState.transactionFilter.of((transaction) => {
// 1
const eventTrigger = transaction.annotation(triggerEvent);
if (eventTrigger == null) {
return transaction;
}
// 2
const document = transaction.state.doc.toString();
// 3
const diagnostic = checkQueryForWildcard(document);
// 4
if (diagnostic == null) {
emitState('loading', transaction.state);
return transaction;
}
// 5
emitState('error', transaction.state);
return [
transaction,
{
effects: [setDiagnosticsEffect.of([diagnostic])],
},
];
});
Here's what's happening with this code:
eventTrigger
is the value of the annotation if it matches the type that we've provided. So in this case, the value will be click
, keyboard
, or null
. We can simply return the transaction and exit the function if the value is null
.transaction.state
, as it will force creation of a state object that may be discarded if the transaction is filtered. Our use case is discriminate enough that we can proceed.diagnosticCount
instead? If this is triggered before linting starts, diagnosticCount
will return 0
, so we need to check ourselves to see if this query is valid.loading
event).error
event and then set the diagnostic we found in CodeMirror state.Now we have a state coordinator...but no React state to coordinate! We'll go there next.
We first need to set up our terminal state:
const [state, setState] = useState<State>('valid query');
By default our terminal gets valid query
as its state until CodeMirror informs it that there is an error with the query. Now that we are tracking state, we can utilize our emitStateToReact
facet to provide the implementation of what should happen when our emitState
calls are made.
const emitStateToReactExtension = emitStateToReact.of((value) => {
if (value !== 'loading') {
setState(value);
return;
}
setState('loading');
setTimeout(() => {
setState('query executed');
}, 1500);
});
The implementation of this function is simple in our context because this terminal is just simulating state changes (and not actually performing the query). But within this function, it could kick off a call to an API or do any number of things. For now, the "API call" is just a timer that sets the state to query executed
when it expires.
We've got one more piece of react state to wire up–our button. For this, we'll need access to the CodeMirror view
object. I'm using React CodeMirror, so I get access to this object via a forward ref; the code for that looks like this:
const codeEditorRef = useRef<ReactCodeMirrorRef>(null);
function handleButtonClick() {
codeEditorRef.current?.view?.dispatch({
annotations: triggerEvent.of('click'),
});
}
Like our keyboard shortcut code, this calls at view.dispatch
, but sets the event to be of type click
instead of keyboard
.
With our React state connection in place, we now have a working editor that covers all of the use cases:
Because of our state coordinator, we have successfully eliminated the race condition. We can be sure that we won't ever run a query that has a wildcard in it, no matter when or how the query is triggered.
If you'd like to see all the code, it's publically available on GitHub. Good luck wrangling state! 🤠
Get my posts in your feed of choice. Opt-out any time.
If you enjoyed this article, you might enjoy one of these: