The example I want to start with from the CodeMirror docs, look like this:
import { basicSetup } from "codemirror"
import { EditorView } from "@codemirror/view"
const view = new EditorView({
doc: "Start document",
parent: document.body,
extensions: [basicSetup]
})
3.2.1 Import statements in ES6
Before using it in a Stimulus controller, let's see what this code actually does. The first two lines are called import statements. Specifically the ES6+ module syntax (remember it from the introduction?). Also the curly braces indicate this is a named import. It's the most common kind of import. This means only the basicSetup
function is imported from the codemirror package.
The codemirror package could have code that might look something like this:
export const basicSetup = () => {}
In Ruby you would require an entire file or module like so: require "library"
. Every method, including private methods, are then available in the class you have added it in. You can see JavaScript allows for more granularity in your imports (though there are workarounds for this in Ruby).
When using Rails you don't have to worry about requiring anything at all, as everything in the app folder is, through Zeitwerk, auto-loaded for you.
Importing functions is something you will be doing a lot in JavaScript, so make sure you have a code snippet for it (or your AI tool knows about it).
You are not limited to importing functions from packages. You can also import from a file like so:
import {scream} from "./some-file.js"
This statement assumes that some-file.js is located in the same directory as the file that is importing it. Its content could be as simple as:
export const scream = () => { console.log("😱") }
In a later chapter I'll be diving deeper into this.
3.2.1.1 How importing works
When JavaScript stumbles upon such import statements, it needs to resolve the module's location. If you use importmap that happens on runtime in the browser (see the output between <script type="importmap">
). If you use esbuild (or similar build tools) this happens during the build process (eg. bin/rails assets:precompile
). Here all imports will be resolved to actual file paths and bundled together into one final output file (typically application-[hash].js).
If a module path starts with /
, ./
, or ../
, it assumes a relative/absolute file path. Otherwise it's treated as a module from node_modules.
3.2.2 Load a third-party library in a Stimulus controller
With a better understanding on how imports works, let's actually transform above basic example into the Stimulus editor controller:
import { Controller } from "@hotwired/stimulus"
import { basicSetup } from "codemirror"
import { EditorView } from "@codemirror/view"
export default class extends Controller {
connect() {
this.editor = new EditorView({
doc: "Hello from Rails Designer",
parent: this.element,
extensions: [basicSetup]
})
}
}
3.2.2.1 Lifecycle methods in Stimulus
I added the example in the connect()
method. Why? Stimulus has three so-called lifecycle methods: initialize, connect and disconnect. Most of the time you want to use connect, but initialize has a clear function too. When to use initialize and when connect?
On page refresh/load: both initialize and connect run. When (re-)adding the same controller (without a page refresh): only connect runs.
So what to add in either method? In initialize you can add initial (ha!) values or counters like:
- timeouts/intervals, eg.
this.counter = 0
; - configuration settings, eg.
this.timeout = setTimeout(this.check, 1000)
; - cache objects, eg.
this.cache = new Map()
.
Then in connect you can put things that need DOM access or things that need to run again:
- event listeners, eg.
this.element.addEventListener("mouseover", this.show())
; - DOM queries, eg.
this.displayElement = this.element.querySelector('[data-controller="editor"]')
; - third-party library initialization, like CodeMirror, eg.
new EditorView({})
.
And what about disconnect? It is the lifecycle method you probably will use the least. It runs when the controller's element is removed from the DOM. This is critical to prevent memory leaks. Examples:
- removing event listeners, eg.
this.element.removeEventListener("mouseover", this.show())
; - cleaning up third-party library instances, eg.
this.thirdPartyLibrary.destroy()
; - cancelling timers or intervals, eg.
clearInterval(this.refreshInterval)
.
3.2.2.2 Semicolons or not?
You might have noticed in the above code the missing semicolon. In JavaScript it is technically optional to add a semicolon (;
) due to Automatic Semicolon Insertion (ASI). The biggest reason to use them is to prevent potential issues with code minification. But many modern JavaScript developers (along with frameworks and tools like StandardJS, Vue) omit them completely.
Adding them though makes the structure of the code more explicit, make code minification easier and prevents potential ASI edge cases. The pros are more about developer happiness: a character less to worry about, less visual noise and in general it just looks nicer.
I am all about developer happiness (as a Ruby developer). As such I omit semicolons in this book. That said, if your team, the OSS code base you work on, or if you personally like them, be consistent and add them as well. Here are some rules when to add them. Sometimes with more modern syntax it is not always super clear.
// After multiple statements on one line
const a = 42; const b = 0; console.log(a);
// After function expressions
["Rails", "Designer"].forEach(word => console.log(word));
// After variable declarations
const greeting = "Hello";
const name = "Cam";
// Arrow functions with implicit returns
const double = n => n * 2;
// After multi-line arrow function with implicit return
const displayUser = user => (
`${user.name} is ${user.age}`
);
3.2.3 Teardown in Stimulus
The disconnect
cleanup (also known as teardown, cleanup or unmount) is needed because CodeMirror's EditorView
creates DOM elements and event listeners that aren't automatically removed when the controller is removed from the DOM.
Without it you'll have memory leaks as old editor instances would stay in memory and continue listening for events even after the element has been removed.
So let's add it:
import { Controller } from "@hotwired/stimulus"
import { basicSetup } from "codemirror"
import { EditorView } from "@codemirror/view"
export default class extends Controller {
connect() {
this.editor = new EditorView({
doc: "Hello from Rails Designer",
parent: this.element,
extensions: [basicSetup]
})
}
disconnect() {
this.editor.destroy()
}
}
This is actually calling destroy()
on an instance of EditorView
as it is set to this.editor
in the connect method.
With the needed Stimulus' lifecycle methods out of the way, let's return to the editor.
3.2.4 Basic code editor in your Rails app
Now when you start the server again and navigate to localhost:3000, you will see an editor on your page with the text Hello from Rails Designer
.
You can see this basic example gives line numbers, current line highlight and adds a closing quotation mark (try typing class="
).
If you inspect the DOM, you will see it is not a typical textarea, but using contenteditable="true"
on the div-element (with the data-controller="editor"
) instead, because many of the features, that will be explored later, need more advanced DOM wrangling. This complex DOM structure houses many nested divs for the various features (that will be introduced in later chapters).
Victory!